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

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.
Files changed (49) hide show
  1. package/ChartWebview.podspec +3 -0
  2. package/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebview.kt +74 -16
  3. package/android/src/main/java/com/margelo/nitro/chartwebview/PooledChartWebView.kt +139 -13
  4. package/ios/ChartWebview.swift +185 -31
  5. package/lib/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +9 -0
  6. package/lib/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +2 -0
  7. package/lib/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +4 -0
  8. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +6 -0
  9. package/lib/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +7 -0
  10. package/lib/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +5 -0
  11. package/lib/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +1 -0
  12. package/lib/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +24 -0
  13. package/lib/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +2 -0
  14. package/lib/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +2 -0
  15. package/lib/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +12 -0
  16. package/lib/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +1 -0
  17. package/lib/nitrogen/generated/shared/json/ChartWebviewConfig.json +1 -0
  18. package/lib/nitrogen/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +9 -0
  19. package/lib/nitrogen/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +2 -0
  20. package/lib/nitrogen/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +4 -0
  21. package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +6 -0
  22. package/lib/nitrogen/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +7 -0
  23. package/lib/nitrogen/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +5 -0
  24. package/lib/nitrogen/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +1 -0
  25. package/lib/nitrogen/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +24 -0
  26. package/lib/nitrogen/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +2 -0
  27. package/lib/nitrogen/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +2 -0
  28. package/lib/nitrogen/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +12 -0
  29. package/lib/nitrogen/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +1 -0
  30. package/lib/nitrogen/nitrogen/generated/shared/json/ChartWebviewConfig.json +1 -0
  31. package/lib/typescript/src/ChartWebview.nitro.d.ts +1 -0
  32. package/lib/typescript/src/ChartWebview.nitro.d.ts.map +1 -1
  33. package/lib/typescript/src/index.d.ts +1 -0
  34. package/lib/typescript/src/index.d.ts.map +1 -1
  35. package/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +9 -0
  36. package/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +2 -0
  37. package/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +4 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +6 -0
  39. package/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +7 -0
  40. package/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +5 -0
  41. package/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +1 -0
  42. package/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +24 -0
  43. package/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +2 -0
  44. package/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +2 -0
  45. package/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +12 -0
  46. package/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +1 -0
  47. package/nitrogen/generated/shared/json/ChartWebviewConfig.json +1 -0
  48. package/package.json +1 -1
  49. package/src/ChartWebview.nitro.ts +10 -0
@@ -21,6 +21,9 @@ Pod::Spec.new do |s|
21
21
 
22
22
  s.dependency 'React-jsi'
23
23
  s.dependency 'React-callinvoker'
24
+ # DEBUG instrumentation: route native chart lifecycle logs into the shared
25
+ # OneKeyLog file (app-latest.log) via `import ReactNativeNativeLogger`.
26
+ s.dependency 'ReactNativeNativeLogger'
24
27
 
25
28
  load 'nitrogen/generated/ios/ChartWebview+autolinking.rb'
26
29
  add_nitrogen_files(s)
@@ -98,34 +98,37 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
98
98
 
99
99
  // --- Source props ---
100
100
 
101
+ // Source/bridge setters call applySource (synchronous) — NOT scheduleReconcile,
102
+ // which caused an infinite reconcile loop (reconcile -> setSource -> prop
103
+ // re-apply -> reconcile ...). applySource sets warmDriver + warm-boots the page
104
+ // even when this host is not the visible owner, so post-attach bridge arrival
105
+ // still makes this host the warm driver (page->native callbacks fall back to it).
101
106
  private var _uri: String? = null
102
107
  override var uri: String?
103
108
  get() = _uri
104
- set(value) { _uri = value; applySourceIfOwner() }
109
+ set(value) { _uri = value; applySource() }
105
110
 
106
111
  private var _localBundle: String? = null
107
112
  override var localBundle: String?
108
113
  get() = _localBundle
109
- set(value) { _localBundle = value; applySourceIfOwner() }
114
+ set(value) { _localBundle = value; applySource() }
110
115
 
111
116
  private var _entry: String? = null
112
117
  override var entry: String?
113
118
  get() = _entry
114
- set(value) { _entry = value; applySourceIfOwner() }
119
+ set(value) { _entry = value; applySource() }
115
120
 
116
121
  private var _paramsJson: String? = null
117
122
  override var paramsJson: String?
118
123
  get() = _paramsJson
119
- set(value) { _paramsJson = value; applySourceIfOwner() }
124
+ set(value) { _paramsJson = value; applySource() }
120
125
 
121
126
  // Document-start bridge JS (single source of truth in the TS layer). Stored and
122
127
  // handed to the pooled WebView when we claim it, before its first load.
123
128
  private var _bridgeScript: String? = null
124
129
  override var bridgeScript: String?
125
130
  get() = _bridgeScript
126
- // Re-apply the source: if this prop lands after the source props (a load can't
127
- // run before the bridge is registered, so setSource deferred), this triggers it.
128
- set(value) { _bridgeScript = value; applySourceIfOwner() }
131
+ set(value) { _bridgeScript = value; applySource() }
129
132
 
130
133
  // --- Singleton props ---
131
134
  // `pooled` + non-empty `reuseKey` => the backing WebView is shared (keyed by
@@ -162,6 +165,22 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
162
165
  scheduleReconcile()
163
166
  }
164
167
 
168
+ // --- Debugging ---
169
+ // Make the backing WebView inspectable via chrome://inspect. Driven by the
170
+ // app's "Enable Native Webview Debugging" dev-mode toggle, mirroring the main
171
+ // react-native-webview (which calls WebView.setWebContentsDebuggingEnabled).
172
+ // Stored and applied to the backing WebView both here (on prop change) and at
173
+ // host claim, since `backing` is null until the first reconcile assigns it.
174
+ // CAVEAT: setWebContentsDebuggingEnabled is PROCESS-GLOBAL — see
175
+ // PooledChartWebView.setInspectable.
176
+ private var _webviewDebuggingEnabled: Boolean? = null
177
+ override var webviewDebuggingEnabled: Boolean?
178
+ get() = _webviewDebuggingEnabled
179
+ set(value) {
180
+ _webviewDebuggingEnabled = value
181
+ backing?.setInspectable(value)
182
+ }
183
+
165
184
  // --- Event callbacks ---
166
185
  override var onMessage: ((message: String) -> Unit)? = null
167
186
  override var onLoadEnd: (() -> Unit)? = null
@@ -219,6 +238,9 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
219
238
  val key = effectiveKey()
220
239
  val entry = ChartWebviewPool.acquireShared(key, context)
221
240
  backing = entry
241
+ // Apply this host's debug preference to the (possibly freshly created) backing
242
+ // WebView. Mirrors the main react-native-webview's setWebContentsDebuggingEnabled.
243
+ entry.setInspectable(_webviewDebuggingEnabled)
222
244
  // Refcount the pool entry once per host (reconcile runs many times). Balanced
223
245
  // by releaseShared in dispose(). If the reuseKey changed, release the old one.
224
246
  if (adoptedPoolKey != key) {
@@ -226,13 +248,24 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
226
248
  ChartWebviewPool.adopt(key)
227
249
  adoptedPoolKey = key
228
250
  }
251
+ // WARM-BOOT (mirror of iOS): boot the shared offline page + register as the
252
+ // warm DRIVER as soon as ANY referencing host has the bridge, even when not
253
+ // the visible owner (offscreen prewarm runs with attached=false/active=false).
254
+ // page->native callbacks fall back to warmDriver when there's no owner, so the
255
+ // bars-state / load-end signals aren't dropped during warm — otherwise the
256
+ // chart stays on the loading mask. Idempotent: setSource dedupes on same URL,
257
+ // setBridgeScript no-ops once registered.
258
+ val bs = _bridgeScript
259
+ if (!bs.isNullOrEmpty()) {
260
+ entry.setBridgeScript(bs)
261
+ entry.warmDriver = this
262
+ entry.setSource(_uri, _localBundle, _entry, _paramsJson)
263
+ }
229
264
  if (wantsOwnership()) {
230
265
  entry.owner = this
231
- // Register the document-start bridge before the first load (the prop is set
232
- // by now; the pool may have been created earlier by a window-attach reconcile).
233
- entry.setBridgeScript(_bridgeScript ?: "")
266
+ // PERF: a host is now showing the chart make sure the renderer is running.
267
+ entry.resume()
234
268
  entry.attachTo(container)
235
- entry.setSource(_uri, _localBundle, _entry, _paramsJson)
236
269
  // Keep a fresh frame of OUR content while we own the WebView, so that when
237
270
  // we later go inactive (and the shared WebView reloads the other slot's
238
271
  // chart) our slot freezes to its own last frame, not the other content.
@@ -248,6 +281,11 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
248
281
  if (wasOwner) entry.owner = null
249
282
  stopOwnCapture()
250
283
  showPlaceholder(ownSnapshot)
284
+ // PERF: if nobody owns the shared WebView now, pause its renderer so it
285
+ // doesn't keep burning a CPU core + GPU + RAM in the background (Android
286
+ // doesn't auto-throttle offscreen WebViews like iOS does). Resumed on the
287
+ // next CLAIM. No-op until the page's first load completed.
288
+ entry.pauseIfIdle()
251
289
  }
252
290
  }
253
291
 
@@ -257,17 +295,28 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
257
295
  if (!wantsOwnership()) return
258
296
  val pooled = backing ?: PooledChartWebView.create(context, effectiveKey())
259
297
  backing = pooled
298
+ pooled.setInspectable(_webviewDebuggingEnabled)
260
299
  pooled.owner = this
261
300
  pooled.setBridgeScript(_bridgeScript ?: "")
262
301
  pooled.attachTo(container)
263
302
  pooled.setSource(_uri, _localBundle, _entry, _paramsJson)
264
303
  }
265
304
 
266
- private fun applySourceIfOwner() {
305
+ // Apply the source synchronously when a source/bridge prop changes. For a
306
+ // pooled host this ALSO registers it as the warmDriver and warm-boots the page
307
+ // even when it is not the visible owner, so page->native callbacks (bars-state /
308
+ // load-end) fall back to it when no host owns the pool. No scheduleReconcile —
309
+ // that looped. backing is null until the first reconcile assigns it (the
310
+ // reuseKey/pooled/active setters still scheduleReconcile), so warmDriver is set
311
+ // on the first prop change after the pool entry is acquired.
312
+ private fun applySource() {
267
313
  val pooled = backing ?: return
268
- if (pooled.owner == this) {
269
- // Register the bridge before any load setSource may trigger (setSource
270
- // defers loading until the bridge is registered).
314
+ val bs = _bridgeScript
315
+ if (isPooled() && !bs.isNullOrEmpty()) {
316
+ pooled.setBridgeScript(bs)
317
+ pooled.warmDriver = this
318
+ pooled.setSource(_uri, _localBundle, _entry, _paramsJson)
319
+ } else if (pooled.owner == this) {
271
320
  pooled.setBridgeScript(_bridgeScript ?: "")
272
321
  pooled.setSource(_uri, _localBundle, _entry, _paramsJson)
273
322
  }
@@ -359,7 +408,7 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
359
408
  // WebView so it doesn't leak a Chromium renderer + JavascriptInterface per
360
409
  // mount/unmount. Pooled (shared) backing is intentionally left alive in the
361
410
  // warm pool — other hosts may still share it; its lifetime is the pool's.
362
- fun dispose() {
411
+ override fun dispose() {
363
412
  stopOwnCapture()
364
413
  container.removeCallbacks(reconcileRunnable)
365
414
  container.removeCallbacks(revealFallbackRunnable)
@@ -368,10 +417,19 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
368
417
  if (pendingRevealHost == this) pendingRevealHost = null
369
418
  val entry = backing
370
419
  if (entry != null && entry.owner == this) entry.owner = null
420
+ if (entry != null && entry.warmDriver == this) entry.warmDriver = null
371
421
  // Balance the pool adopt() if this host ever joined a pooled key. Kept warm by
372
422
  // default (single-instance cache); the release path makes destroy() reachable.
373
423
  adoptedPoolKey?.let { ChartWebviewPool.releaseShared(it) }
374
424
  adoptedPoolKey = null
425
+ // Pooled host going away: release the shared WebView from THIS host's
426
+ // container, which is about to be dropped from the view tree. The pool keeps
427
+ // the WebView alive (warm) for the next host, but if we leave it parented to
428
+ // our dropped container the next host's attach can't clear that stale parent
429
+ // (removeView on a detached container isn't applied synchronously) and retries
430
+ // forever → blank/white chart. detachFrom is parent-checked, so if another host
431
+ // already re-claimed the WebView we leave it where it is.
432
+ if (entry != null && isPooled()) entry.detachFrom(container)
375
433
  // A private (non-pooled) instance is owned solely by this host — tear it down
376
434
  // so it doesn't leak a Chromium renderer + JavascriptInterface.
377
435
  if (entry != null && !isPooled()) entry.destroy()
@@ -52,6 +52,9 @@ class PooledChartWebView private constructor(
52
52
  const val ASSET_HOST = "appassets.androidplatform.net"
53
53
  private const val DEFAULT_ENTRY = "index.html"
54
54
 
55
+ // Operational log tag for this pool (lifecycle + error paths only).
56
+ private const val TAG = "ChartWebviewPool"
57
+
55
58
  // Tiny platform transport shim: defines the single hook the shared TS bridge
56
59
  // (CHART_BRIDGE_JS) calls. The bulk of the bridge lives in the TS layer and
57
60
  // arrives via the `bridgeScript` ctor arg — this is the only platform-specific
@@ -148,7 +151,63 @@ class PooledChartWebView private constructor(
148
151
  }
149
152
 
150
153
  /** The host currently displaying this WebView; page events route here. */
151
- var owner: HybridChartWebview? = null
154
+ private var _owner: HybridChartWebview? = null
155
+ var owner: HybridChartWebview?
156
+ get() = _owner
157
+ set(value) {
158
+ _owner = value
159
+ }
160
+ // The host that warm-booted the page and can drive its symbol / receive its
161
+ // callbacks while there is no VISIBLE owner yet. Separate from `owner` (the
162
+ // YIELD path clears `owner`); callbacks fall back to this so bars-state /
163
+ // load-end aren't dropped during warm. Mirror of the iOS warmDriver.
164
+ private var _warmDriver: HybridChartWebview? = null
165
+ var warmDriver: HybridChartWebview?
166
+ get() = _warmDriver
167
+ set(value) {
168
+ _warmDriver = value
169
+ }
170
+
171
+ // PERF (Android only): Android's in-process WebView/Chromium does NOT throttle
172
+ // an offscreen/unowned page (unlike iOS WKWebView). Left running, the warm
173
+ // pooled page keeps its rAF render loop + websockets + compositing alive
174
+ // FOREVER after the user leaves the chart — pinning a CPU core, growing RAM to
175
+ // OOM, and stealing the GPU from RN's RenderThread (every other screen stalls).
176
+ // We pause the WebView whenever no host owns it (after the first load) and
177
+ // resume on claim. Uses the PER-INSTANCE onPause()/onResume() — NOT the static
178
+ // pauseTimers()/resumeTimers(), which are process-global and would also freeze
179
+ // the app's other WebViews (inpage provider / web-embed / dapp browser).
180
+ private var paused = false
181
+ private var hasLoadedOnce = false
182
+
183
+ fun markLoaded() {
184
+ hasLoadedOnce = true
185
+ }
186
+
187
+ // Pause the renderer when nobody owns the shared WebView. onPause() stops
188
+ // drawing/compositing/animations (frees the GPU so navigation is smooth again)
189
+ // WITHOUT changing the view's visibility/attachment — we must NOT toggle
190
+ // visibility here, that left the WebView blank/white after re-claim. Skipped
191
+ // until the first load so we never freeze a booting page. Resumed (+ redraw)
192
+ // on the next CLAIM so the chart paints again.
193
+ fun pauseIfIdle() {
194
+ if (paused || !hasLoadedOnce || owner != null) {
195
+ return
196
+ }
197
+ paused = true
198
+ android.util.Log.i(TAG, "pool[$key] PAUSE (renderer idle)")
199
+ runOnUiThread { webView.onPause() }
200
+ }
201
+
202
+ fun resume() {
203
+ if (!paused) return
204
+ paused = false
205
+ android.util.Log.i(TAG, "pool[$key] RESUME")
206
+ runOnUiThread {
207
+ webView.onResume()
208
+ webView.invalidate()
209
+ }
210
+ }
152
211
 
153
212
  private var assetLoader: WebViewAssetLoader? = null
154
213
  private var lastLoadedUrl: String? = null
@@ -179,9 +238,28 @@ class PooledChartWebView private constructor(
179
238
  addJavascriptInterface(ChartBridge(), "AndroidChartBridge")
180
239
  }
181
240
 
241
+ // Apply the app's "Enable Native Webview Debugging" dev-mode toggle, mirroring
242
+ // how the main react-native-webview calls WebView.setWebContentsDebuggingEnabled.
243
+ // Called by the host both on the prop change and at host claim, so the toggle is
244
+ // honored even when this entry was created before the prop arrived.
245
+ //
246
+ // CAVEAT (PROCESS-GLOBAL): setWebContentsDebuggingEnabled is a STATIC method that
247
+ // flips remote-debugging for EVERY WebView in the whole process. Once any WebView
248
+ // (this chart, the main react-native-webview, etc.) enables it, it stays enabled
249
+ // process-wide until the app is killed — Android exposes no per-WebView toggle and
250
+ // no way to read the current value. So a null/false preference here cannot turn
251
+ // debugging back OFF once another WebView (or a prior true value) turned it ON; it
252
+ // simply does not re-enable it. We therefore only ever call the setter with `true`
253
+ // when explicitly enabled, leaving the process-global state untouched otherwise.
254
+ fun setInspectable(enabled: Boolean?) {
255
+ if (enabled == true) {
256
+ runOnUiThread { WebView.setWebContentsDebuggingEnabled(true) }
257
+ }
258
+ }
259
+
182
260
  init {
183
261
  val n = liveCount.incrementAndGet()
184
- android.util.Log.d("ChartWebviewPool", "WebView CREATED key=$key liveCount=$n")
262
+ android.util.Log.d(TAG, "WebView CREATED key=$key liveCount=$n")
185
263
 
186
264
  webView.webViewClient = object : WebViewClientCompat() {
187
265
  override fun shouldInterceptRequest(
@@ -191,11 +269,12 @@ class PooledChartWebView private constructor(
191
269
 
192
270
  override fun onPageFinished(view: WebView, url: String?) {
193
271
  super.onPageFinished(view, url)
272
+ markLoaded()
194
273
  // The bridge is delivered exclusively via the origin-scoped document-start
195
274
  // script (see registerBridgeForOrigins). We deliberately do NOT re-inject
196
275
  // it here via evaluateJavascript — that ran on every page event regardless
197
276
  // of URL and would expose the privileged bridge to untrusted pages/frames.
198
- owner?.dispatchLoadEnd()
277
+ (owner ?: warmDriver)?.dispatchLoadEnd()
199
278
  // Prime the snapshot so the first move already has a frame to mask with.
200
279
  refreshSnapshotSoon()
201
280
  }
@@ -217,7 +296,26 @@ class PooledChartWebView private constructor(
217
296
  private inner class ChartBridge {
218
297
  @JavascriptInterface
219
298
  fun postMessage(message: String) {
220
- runOnUiThread { owner?.dispatchMessage(message) }
299
+ runOnUiThread {
300
+ val target = owner ?: warmDriver
301
+ target?.dispatchMessage(message)
302
+ }
303
+ }
304
+ }
305
+
306
+ // Robustly detach the WebView from [parent], even when [parent] is a disposed
307
+ // host container that is mid-teardown / detached from the window and holds the
308
+ // child in a transition / disappearing-children list — where a plain removeView()
309
+ // is deferred and leaves webView.parent set, stranding the shared WebView and
310
+ // blanking the next chart slot. endViewTransition() clears any pending transition
311
+ // hold; removeViewInLayout() is the in-layout fallback if the child is still held.
312
+ private fun forceDetach(parent: ViewGroup) {
313
+ if (webView.parent !== parent) return
314
+ try { parent.endViewTransition(webView) } catch (e: Throwable) {}
315
+ parent.removeView(webView)
316
+ if (webView.parent === parent) {
317
+ try { parent.removeViewInLayout(webView) } catch (e: Throwable) {}
318
+ parent.requestLayout()
221
319
  }
222
320
  }
223
321
 
@@ -225,23 +323,30 @@ class PooledChartWebView private constructor(
225
323
  fun attachTo(container: ViewGroup) {
226
324
  val generation = attachGeneration.incrementAndGet()
227
325
  runOnUiThread {
228
- attachToContainer(container, generation, canRetry = true)
326
+ attachToContainer(container, generation, retriesLeft = 12)
229
327
  }
230
328
  }
231
329
 
232
- private fun attachToContainer(container: ViewGroup, generation: Int, canRetry: Boolean) {
330
+ private fun attachToContainer(container: ViewGroup, generation: Int, retriesLeft: Int) {
233
331
  if (generation != attachGeneration.get()) return
234
332
  if (webView.parent === container) return
235
333
 
236
- (webView.parent as? ViewGroup)?.removeView(webView)
334
+ (webView.parent as? ViewGroup)?.let { forceDetach(it) }
237
335
  val currentParent = webView.parent
238
- if (currentParent != null) {
239
- if (canRetry) {
240
- webView.post { attachToContainer(container, generation, canRetry = false) }
336
+ if (currentParent != null && currentParent !== container) {
337
+ // The old container still hasn't released the WebView (a disposed host's
338
+ // container mid-teardown / detached from window: removeView is deferred and
339
+ // getParent() stays set). Retry — but on the TARGET container's handler,
340
+ // which is attached to the window so its queue keeps draining, instead of the
341
+ // detached webView/old parent whose post() runnables may never run.
342
+ if (retriesLeft > 0) {
343
+ container.post {
344
+ attachToContainer(container, generation, retriesLeft = retriesLeft - 1)
345
+ }
241
346
  } else {
242
347
  android.util.Log.w(
243
- "ChartWebviewPool",
244
- "Skip attach key=$key because WebView parent was not cleared: $currentParent",
348
+ TAG,
349
+ "Skip attach key=$key after retries because WebView parent was not cleared: $currentParent",
245
350
  )
246
351
  }
247
352
  return
@@ -260,6 +365,27 @@ class PooledChartWebView private constructor(
260
365
  refreshSnapshotSoon()
261
366
  }
262
367
 
368
+ /**
369
+ * Remove the WebView from [container] ONLY if it is still parented there.
370
+ *
371
+ * Called on a pooled host's final teardown (dispose): that host's container is
372
+ * about to be dropped from the view tree, and a pooled host deliberately does
373
+ * NOT detach on the YIELD path (to avoid racing a rapid re-claim). Without this,
374
+ * the shared WebView stays parented to the dropped/detached container; the next
375
+ * host's [attachTo] then keeps hitting "old parent not cleared" (removeView on a
376
+ * detached container isn't applied synchronously) and retries forever — leaving
377
+ * the new chart slot blank/white. The `parent === container` guard makes this
378
+ * safe against ordering: if a new host has already re-claimed and reparented the
379
+ * WebView, we must NOT rip it back off, so we only remove when WE still hold it.
380
+ */
381
+ fun detachFrom(container: ViewGroup) {
382
+ runOnUiThread {
383
+ if (webView.parent === container) {
384
+ forceDetach(container)
385
+ }
386
+ }
387
+ }
388
+
263
389
  /** Remove the WebView from its current parent (keeps it alive, warm). */
264
390
  fun detachFromParent() {
265
391
  runOnUiThread {
@@ -437,7 +563,7 @@ class PooledChartWebView private constructor(
437
563
  (webView.parent as? ViewGroup)?.removeView(webView)
438
564
  webView.destroy()
439
565
  val n = liveCount.decrementAndGet()
440
- android.util.Log.d("ChartWebviewPool", "WebView DESTROYED key=$key liveCount=$n")
566
+ android.util.Log.d(TAG, "WebView DESTROYED key=$key liveCount=$n")
441
567
  }
442
568
  }
443
569