@onekeyfe/react-native-chart-webview 3.0.55 → 3.0.57

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 +85 -18
  3. package/android/src/main/java/com/margelo/nitro/chartwebview/PooledChartWebView.kt +185 -23
  4. package/ios/ChartWebview.swift +190 -31
  5. package/lib/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +18 -0
  6. package/lib/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +4 -0
  7. package/lib/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +8 -0
  8. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +12 -0
  9. package/lib/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +14 -0
  10. package/lib/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +10 -0
  11. package/lib/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +2 -0
  12. package/lib/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +48 -0
  13. package/lib/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +4 -0
  14. package/lib/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +4 -0
  15. package/lib/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +24 -0
  16. package/lib/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +2 -0
  17. package/lib/nitrogen/generated/shared/json/ChartWebviewConfig.json +2 -0
  18. package/lib/nitrogen/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +18 -0
  19. package/lib/nitrogen/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +4 -0
  20. package/lib/nitrogen/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +8 -0
  21. package/lib/nitrogen/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +12 -0
  22. package/lib/nitrogen/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +14 -0
  23. package/lib/nitrogen/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +10 -0
  24. package/lib/nitrogen/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +2 -0
  25. package/lib/nitrogen/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +48 -0
  26. package/lib/nitrogen/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +4 -0
  27. package/lib/nitrogen/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +4 -0
  28. package/lib/nitrogen/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +24 -0
  29. package/lib/nitrogen/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +2 -0
  30. package/lib/nitrogen/nitrogen/generated/shared/json/ChartWebviewConfig.json +2 -0
  31. package/lib/typescript/src/ChartWebview.nitro.d.ts +2 -0
  32. package/lib/typescript/src/ChartWebview.nitro.d.ts.map +1 -1
  33. package/lib/typescript/src/index.d.ts +2 -0
  34. package/lib/typescript/src/index.d.ts.map +1 -1
  35. package/nitrogen/generated/android/c++/JHybridChartWebviewSpec.cpp +18 -0
  36. package/nitrogen/generated/android/c++/JHybridChartWebviewSpec.hpp +4 -0
  37. package/nitrogen/generated/android/c++/views/JHybridChartWebviewStateUpdater.cpp +8 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/chartwebview/HybridChartWebviewSpec.kt +12 -0
  39. package/nitrogen/generated/ios/c++/HybridChartWebviewSpecSwift.hpp +14 -0
  40. package/nitrogen/generated/ios/c++/views/HybridChartWebviewComponent.mm +10 -0
  41. package/nitrogen/generated/ios/swift/HybridChartWebviewSpec.swift +2 -0
  42. package/nitrogen/generated/ios/swift/HybridChartWebviewSpec_cxx.swift +48 -0
  43. package/nitrogen/generated/shared/c++/HybridChartWebviewSpec.cpp +4 -0
  44. package/nitrogen/generated/shared/c++/HybridChartWebviewSpec.hpp +4 -0
  45. package/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.cpp +24 -0
  46. package/nitrogen/generated/shared/c++/views/HybridChartWebviewComponent.hpp +2 -0
  47. package/nitrogen/generated/shared/json/ChartWebviewConfig.json +2 -0
  48. package/package.json +1 -1
  49. package/src/ChartWebview.nitro.ts +18 -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,46 @@ 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() }
125
+
126
+ // ANDROID: the host the offline `localBundle` is served under. When set, the
127
+ // offline bundle is served from https://<assetHost>/ so the WebView reuses the
128
+ // real chart origin's same-origin storage (zero migration). Falls back to the
129
+ // built-in appassets host in the pool when null. (iOS ignores this prop.)
130
+ private var _assetHost: String? = null
131
+ override var assetHost: String?
132
+ get() = _assetHost
133
+ set(value) { _assetHost = value; applySource() }
120
134
 
121
135
  // Document-start bridge JS (single source of truth in the TS layer). Stored and
122
136
  // handed to the pooled WebView when we claim it, before its first load.
123
137
  private var _bridgeScript: String? = null
124
138
  override var bridgeScript: String?
125
139
  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() }
140
+ set(value) { _bridgeScript = value; applySource() }
129
141
 
130
142
  // --- Singleton props ---
131
143
  // `pooled` + non-empty `reuseKey` => the backing WebView is shared (keyed by
@@ -162,6 +174,22 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
162
174
  scheduleReconcile()
163
175
  }
164
176
 
177
+ // --- Debugging ---
178
+ // Make the backing WebView inspectable via chrome://inspect. Driven by the
179
+ // app's "Enable Native Webview Debugging" dev-mode toggle, mirroring the main
180
+ // react-native-webview (which calls WebView.setWebContentsDebuggingEnabled).
181
+ // Stored and applied to the backing WebView both here (on prop change) and at
182
+ // host claim, since `backing` is null until the first reconcile assigns it.
183
+ // CAVEAT: setWebContentsDebuggingEnabled is PROCESS-GLOBAL — see
184
+ // PooledChartWebView.setInspectable.
185
+ private var _webviewDebuggingEnabled: Boolean? = null
186
+ override var webviewDebuggingEnabled: Boolean?
187
+ get() = _webviewDebuggingEnabled
188
+ set(value) {
189
+ _webviewDebuggingEnabled = value
190
+ backing?.setInspectable(value)
191
+ }
192
+
165
193
  // --- Event callbacks ---
166
194
  override var onMessage: ((message: String) -> Unit)? = null
167
195
  override var onLoadEnd: (() -> Unit)? = null
@@ -219,6 +247,9 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
219
247
  val key = effectiveKey()
220
248
  val entry = ChartWebviewPool.acquireShared(key, context)
221
249
  backing = entry
250
+ // Apply this host's debug preference to the (possibly freshly created) backing
251
+ // WebView. Mirrors the main react-native-webview's setWebContentsDebuggingEnabled.
252
+ entry.setInspectable(_webviewDebuggingEnabled)
222
253
  // Refcount the pool entry once per host (reconcile runs many times). Balanced
223
254
  // by releaseShared in dispose(). If the reuseKey changed, release the old one.
224
255
  if (adoptedPoolKey != key) {
@@ -226,13 +257,24 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
226
257
  ChartWebviewPool.adopt(key)
227
258
  adoptedPoolKey = key
228
259
  }
260
+ // WARM-BOOT (mirror of iOS): boot the shared offline page + register as the
261
+ // warm DRIVER as soon as ANY referencing host has the bridge, even when not
262
+ // the visible owner (offscreen prewarm runs with attached=false/active=false).
263
+ // page->native callbacks fall back to warmDriver when there's no owner, so the
264
+ // bars-state / load-end signals aren't dropped during warm — otherwise the
265
+ // chart stays on the loading mask. Idempotent: setSource dedupes on same URL,
266
+ // setBridgeScript no-ops once registered.
267
+ val bs = _bridgeScript
268
+ if (!bs.isNullOrEmpty()) {
269
+ entry.setBridgeScript(bs)
270
+ entry.warmDriver = this
271
+ entry.setSource(_uri, _localBundle, _entry, _paramsJson, _assetHost)
272
+ }
229
273
  if (wantsOwnership()) {
230
274
  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 ?: "")
275
+ // PERF: a host is now showing the chart make sure the renderer is running.
276
+ entry.resume()
234
277
  entry.attachTo(container)
235
- entry.setSource(_uri, _localBundle, _entry, _paramsJson)
236
278
  // Keep a fresh frame of OUR content while we own the WebView, so that when
237
279
  // we later go inactive (and the shared WebView reloads the other slot's
238
280
  // chart) our slot freezes to its own last frame, not the other content.
@@ -248,6 +290,11 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
248
290
  if (wasOwner) entry.owner = null
249
291
  stopOwnCapture()
250
292
  showPlaceholder(ownSnapshot)
293
+ // PERF: if nobody owns the shared WebView now, pause its renderer so it
294
+ // doesn't keep burning a CPU core + GPU + RAM in the background (Android
295
+ // doesn't auto-throttle offscreen WebViews like iOS does). Resumed on the
296
+ // next CLAIM. No-op until the page's first load completed.
297
+ entry.pauseIfIdle()
251
298
  }
252
299
  }
253
300
 
@@ -257,19 +304,30 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
257
304
  if (!wantsOwnership()) return
258
305
  val pooled = backing ?: PooledChartWebView.create(context, effectiveKey())
259
306
  backing = pooled
307
+ pooled.setInspectable(_webviewDebuggingEnabled)
260
308
  pooled.owner = this
261
309
  pooled.setBridgeScript(_bridgeScript ?: "")
262
310
  pooled.attachTo(container)
263
- pooled.setSource(_uri, _localBundle, _entry, _paramsJson)
311
+ pooled.setSource(_uri, _localBundle, _entry, _paramsJson, _assetHost)
264
312
  }
265
313
 
266
- private fun applySourceIfOwner() {
314
+ // Apply the source synchronously when a source/bridge prop changes. For a
315
+ // pooled host this ALSO registers it as the warmDriver and warm-boots the page
316
+ // even when it is not the visible owner, so page->native callbacks (bars-state /
317
+ // load-end) fall back to it when no host owns the pool. No scheduleReconcile —
318
+ // that looped. backing is null until the first reconcile assigns it (the
319
+ // reuseKey/pooled/active setters still scheduleReconcile), so warmDriver is set
320
+ // on the first prop change after the pool entry is acquired.
321
+ private fun applySource() {
267
322
  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).
323
+ val bs = _bridgeScript
324
+ if (isPooled() && !bs.isNullOrEmpty()) {
325
+ pooled.setBridgeScript(bs)
326
+ pooled.warmDriver = this
327
+ pooled.setSource(_uri, _localBundle, _entry, _paramsJson, _assetHost)
328
+ } else if (pooled.owner == this) {
271
329
  pooled.setBridgeScript(_bridgeScript ?: "")
272
- pooled.setSource(_uri, _localBundle, _entry, _paramsJson)
330
+ pooled.setSource(_uri, _localBundle, _entry, _paramsJson, _assetHost)
273
331
  }
274
332
  }
275
333
 
@@ -359,7 +417,7 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
359
417
  // WebView so it doesn't leak a Chromium renderer + JavascriptInterface per
360
418
  // mount/unmount. Pooled (shared) backing is intentionally left alive in the
361
419
  // warm pool — other hosts may still share it; its lifetime is the pool's.
362
- fun dispose() {
420
+ override fun dispose() {
363
421
  stopOwnCapture()
364
422
  container.removeCallbacks(reconcileRunnable)
365
423
  container.removeCallbacks(revealFallbackRunnable)
@@ -368,10 +426,19 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
368
426
  if (pendingRevealHost == this) pendingRevealHost = null
369
427
  val entry = backing
370
428
  if (entry != null && entry.owner == this) entry.owner = null
429
+ if (entry != null && entry.warmDriver == this) entry.warmDriver = null
371
430
  // Balance the pool adopt() if this host ever joined a pooled key. Kept warm by
372
431
  // default (single-instance cache); the release path makes destroy() reachable.
373
432
  adoptedPoolKey?.let { ChartWebviewPool.releaseShared(it) }
374
433
  adoptedPoolKey = null
434
+ // Pooled host going away: release the shared WebView from THIS host's
435
+ // container, which is about to be dropped from the view tree. The pool keeps
436
+ // the WebView alive (warm) for the next host, but if we leave it parented to
437
+ // our dropped container the next host's attach can't clear that stale parent
438
+ // (removeView on a detached container isn't applied synchronously) and retries
439
+ // forever → blank/white chart. detachFrom is parent-checked, so if another host
440
+ // already re-claimed the WebView we leave it where it is.
441
+ if (entry != null && isPooled()) entry.detachFrom(container)
375
442
  // A private (non-pooled) instance is owned solely by this host — tear it down
376
443
  // so it doesn't leak a Chromium renderer + JavascriptInterface.
377
444
  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
@@ -123,12 +126,12 @@ class PooledChartWebView private constructor(
123
126
  }
124
127
 
125
128
  // 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.
129
+ // (https://<assetHost>, default appassets.androidplatform.net) always, plus the
130
+ // online/fallback `uri` origin when running in online mode. computeTargetUrl
131
+ // serves offline content from `assetHost` and online content from `uri`, so
132
+ // these are exactly the origins the privileged bridge legitimately runs in.
130
133
  private fun trustedOriginsFor(uri: String?): Set<String> {
131
- val origins = linkedSetOf("https://$ASSET_HOST")
134
+ val origins = linkedSetOf("https://$assetHost")
132
135
  originOf(uri)?.let { origins.add(it) }
133
136
  return origins
134
137
  }
@@ -148,11 +151,74 @@ 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
155
214
  private var lastLocalBundle: String? = null
215
+ // The host the offline bundle is served under. Defaults to the built-in
216
+ // appassets host (old behavior); when the app passes `assetHost` we serve the
217
+ // bundle under the real chart origin so the WebView reuses that origin's
218
+ // same-origin storage (zero migration). Tracked so an assetHost change rebuilds
219
+ // the asset loader + recomputes the target URL, same as a localBundle change.
220
+ private var assetHost: String = ASSET_HOST
221
+ private var lastAssetHost: String = ASSET_HOST
156
222
 
157
223
  // Last rendered frame, used to mask the brief blank frame while the WebView's
158
224
  // surface is torn down and recreated during a reparent (move). Kept until
@@ -179,9 +245,28 @@ class PooledChartWebView private constructor(
179
245
  addJavascriptInterface(ChartBridge(), "AndroidChartBridge")
180
246
  }
181
247
 
248
+ // Apply the app's "Enable Native Webview Debugging" dev-mode toggle, mirroring
249
+ // how the main react-native-webview calls WebView.setWebContentsDebuggingEnabled.
250
+ // Called by the host both on the prop change and at host claim, so the toggle is
251
+ // honored even when this entry was created before the prop arrived.
252
+ //
253
+ // CAVEAT (PROCESS-GLOBAL): setWebContentsDebuggingEnabled is a STATIC method that
254
+ // flips remote-debugging for EVERY WebView in the whole process. Once any WebView
255
+ // (this chart, the main react-native-webview, etc.) enables it, it stays enabled
256
+ // process-wide until the app is killed — Android exposes no per-WebView toggle and
257
+ // no way to read the current value. So a null/false preference here cannot turn
258
+ // debugging back OFF once another WebView (or a prior true value) turned it ON; it
259
+ // simply does not re-enable it. We therefore only ever call the setter with `true`
260
+ // when explicitly enabled, leaving the process-global state untouched otherwise.
261
+ fun setInspectable(enabled: Boolean?) {
262
+ if (enabled == true) {
263
+ runOnUiThread { WebView.setWebContentsDebuggingEnabled(true) }
264
+ }
265
+ }
266
+
182
267
  init {
183
268
  val n = liveCount.incrementAndGet()
184
- android.util.Log.d("ChartWebviewPool", "WebView CREATED key=$key liveCount=$n")
269
+ android.util.Log.d(TAG, "WebView CREATED key=$key liveCount=$n")
185
270
 
186
271
  webView.webViewClient = object : WebViewClientCompat() {
187
272
  override fun shouldInterceptRequest(
@@ -191,11 +276,12 @@ class PooledChartWebView private constructor(
191
276
 
192
277
  override fun onPageFinished(view: WebView, url: String?) {
193
278
  super.onPageFinished(view, url)
279
+ markLoaded()
194
280
  // The bridge is delivered exclusively via the origin-scoped document-start
195
281
  // script (see registerBridgeForOrigins). We deliberately do NOT re-inject
196
282
  // it here via evaluateJavascript — that ran on every page event regardless
197
283
  // of URL and would expose the privileged bridge to untrusted pages/frames.
198
- owner?.dispatchLoadEnd()
284
+ (owner ?: warmDriver)?.dispatchLoadEnd()
199
285
  // Prime the snapshot so the first move already has a frame to mask with.
200
286
  refreshSnapshotSoon()
201
287
  }
@@ -217,7 +303,26 @@ class PooledChartWebView private constructor(
217
303
  private inner class ChartBridge {
218
304
  @JavascriptInterface
219
305
  fun postMessage(message: String) {
220
- runOnUiThread { owner?.dispatchMessage(message) }
306
+ runOnUiThread {
307
+ val target = owner ?: warmDriver
308
+ target?.dispatchMessage(message)
309
+ }
310
+ }
311
+ }
312
+
313
+ // Robustly detach the WebView from [parent], even when [parent] is a disposed
314
+ // host container that is mid-teardown / detached from the window and holds the
315
+ // child in a transition / disappearing-children list — where a plain removeView()
316
+ // is deferred and leaves webView.parent set, stranding the shared WebView and
317
+ // blanking the next chart slot. endViewTransition() clears any pending transition
318
+ // hold; removeViewInLayout() is the in-layout fallback if the child is still held.
319
+ private fun forceDetach(parent: ViewGroup) {
320
+ if (webView.parent !== parent) return
321
+ try { parent.endViewTransition(webView) } catch (e: Throwable) {}
322
+ parent.removeView(webView)
323
+ if (webView.parent === parent) {
324
+ try { parent.removeViewInLayout(webView) } catch (e: Throwable) {}
325
+ parent.requestLayout()
221
326
  }
222
327
  }
223
328
 
@@ -225,23 +330,30 @@ class PooledChartWebView private constructor(
225
330
  fun attachTo(container: ViewGroup) {
226
331
  val generation = attachGeneration.incrementAndGet()
227
332
  runOnUiThread {
228
- attachToContainer(container, generation, canRetry = true)
333
+ attachToContainer(container, generation, retriesLeft = 12)
229
334
  }
230
335
  }
231
336
 
232
- private fun attachToContainer(container: ViewGroup, generation: Int, canRetry: Boolean) {
337
+ private fun attachToContainer(container: ViewGroup, generation: Int, retriesLeft: Int) {
233
338
  if (generation != attachGeneration.get()) return
234
339
  if (webView.parent === container) return
235
340
 
236
- (webView.parent as? ViewGroup)?.removeView(webView)
341
+ (webView.parent as? ViewGroup)?.let { forceDetach(it) }
237
342
  val currentParent = webView.parent
238
- if (currentParent != null) {
239
- if (canRetry) {
240
- webView.post { attachToContainer(container, generation, canRetry = false) }
343
+ if (currentParent != null && currentParent !== container) {
344
+ // The old container still hasn't released the WebView (a disposed host's
345
+ // container mid-teardown / detached from window: removeView is deferred and
346
+ // getParent() stays set). Retry — but on the TARGET container's handler,
347
+ // which is attached to the window so its queue keeps draining, instead of the
348
+ // detached webView/old parent whose post() runnables may never run.
349
+ if (retriesLeft > 0) {
350
+ container.post {
351
+ attachToContainer(container, generation, retriesLeft = retriesLeft - 1)
352
+ }
241
353
  } else {
242
354
  android.util.Log.w(
243
- "ChartWebviewPool",
244
- "Skip attach key=$key because WebView parent was not cleared: $currentParent",
355
+ TAG,
356
+ "Skip attach key=$key after retries because WebView parent was not cleared: $currentParent",
245
357
  )
246
358
  }
247
359
  return
@@ -260,6 +372,27 @@ class PooledChartWebView private constructor(
260
372
  refreshSnapshotSoon()
261
373
  }
262
374
 
375
+ /**
376
+ * Remove the WebView from [container] ONLY if it is still parented there.
377
+ *
378
+ * Called on a pooled host's final teardown (dispose): that host's container is
379
+ * about to be dropped from the view tree, and a pooled host deliberately does
380
+ * NOT detach on the YIELD path (to avoid racing a rapid re-claim). Without this,
381
+ * the shared WebView stays parented to the dropped/detached container; the next
382
+ * host's [attachTo] then keeps hitting "old parent not cleared" (removeView on a
383
+ * detached container isn't applied synchronously) and retries forever — leaving
384
+ * the new chart slot blank/white. The `parent === container` guard makes this
385
+ * safe against ordering: if a new host has already re-claimed and reparented the
386
+ * WebView, we must NOT rip it back off, so we only remove when WE still hold it.
387
+ */
388
+ fun detachFrom(container: ViewGroup) {
389
+ runOnUiThread {
390
+ if (webView.parent === container) {
391
+ forceDetach(container)
392
+ }
393
+ }
394
+ }
395
+
263
396
  /** Remove the WebView from its current parent (keeps it alive, warm). */
264
397
  fun detachFromParent() {
265
398
  runOnUiThread {
@@ -389,13 +522,23 @@ class PooledChartWebView private constructor(
389
522
  * so reparenting / redundant prop re-applies never reload (which would lose
390
523
  * chart state, the whole point of pooling).
391
524
  */
392
- fun setSource(uri: String?, localBundle: String?, entry: String?, paramsJson: String?) {
525
+ fun setSource(
526
+ uri: String?,
527
+ localBundle: String?,
528
+ entry: String?,
529
+ paramsJson: String?,
530
+ assetHost: String?,
531
+ ) {
393
532
  // Never load before the document-start bridge is registered — otherwise the
394
533
  // page boots without the bridge and its first $private requests are lost. The
395
534
  // host re-calls setSource once the bridgeScript prop arrives.
396
535
  if (!bridgeRegistered) return
397
- if (localBundle != lastLocalBundle) {
536
+ // Per-instance asset host: fall back to the built-in appassets host (old
537
+ // behavior) when the app doesn't pass one. Empty string is treated as absent.
538
+ this.assetHost = assetHost?.takeIf { it.isNotEmpty() } ?: ASSET_HOST
539
+ if (localBundle != lastLocalBundle || this.assetHost != lastAssetHost) {
398
540
  lastLocalBundle = localBundle
541
+ lastAssetHost = this.assetHost
399
542
  rebuildAssetLoader(localBundle)
400
543
  }
401
544
  // Register the document-start bridge scoped to exactly the origin(s) this load
@@ -437,7 +580,7 @@ class PooledChartWebView private constructor(
437
580
  (webView.parent as? ViewGroup)?.removeView(webView)
438
581
  webView.destroy()
439
582
  val n = liveCount.decrementAndGet()
440
- android.util.Log.d("ChartWebviewPool", "WebView DESTROYED key=$key liveCount=$n")
583
+ android.util.Log.d(TAG, "WebView DESTROYED key=$key liveCount=$n")
441
584
  }
442
585
  }
443
586
 
@@ -446,9 +589,28 @@ class PooledChartWebView private constructor(
446
589
  assetLoader = null
447
590
  return
448
591
  }
592
+ // Narrow the path handler to ONLY the offline bundle subtree
593
+ // (/<localBundle>/, e.g. /tradingview-assets/) instead of intercepting all of
594
+ // "/". When the bundle is served under the real chart origin (`assetHost`),
595
+ // intercepting "/" would shadow every web path on that domain; with the narrow
596
+ // handler, unmatched paths fall through to the network — so an `assetHost`
597
+ // that is a real public domain keeps behaving normally off-bundle.
598
+ // computeTargetUrl produces https://<assetHost>/<localBundle>/<entry>, which
599
+ // stays inside this handler's path. (Path built as /<localBundle>/ so a
600
+ // localBundle that already has a trailing slash doesn't double it.)
601
+ val bundleDir = localBundle.trim('/')
602
+ val bundlePath = "/$bundleDir/"
603
+ // WebViewAssetLoader strips the registered prefix before calling the handler,
604
+ // so AssetsPathHandler alone would resolve the remainder against the assets
605
+ // ROOT (assets/<entry>) and lose the bundle namespace. Re-prepend <bundleDir>/
606
+ // so we still serve from assets/<localBundle>/<entry>. Returning null on a
607
+ // miss lets WebViewAssetLoader fall through to the network.
608
+ val assets = WebViewAssetLoader.AssetsPathHandler(webView.context)
609
+ val namespacedHandler =
610
+ WebViewAssetLoader.PathHandler { suffix -> assets.handle("$bundleDir/$suffix") }
449
611
  assetLoader = WebViewAssetLoader.Builder()
450
- .setDomain(ASSET_HOST)
451
- .addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(webView.context))
612
+ .setDomain(assetHost)
613
+ .addPathHandler(bundlePath, namespacedHandler)
452
614
  .build()
453
615
  }
454
616
 
@@ -464,7 +626,7 @@ class PooledChartWebView private constructor(
464
626
  val query = buildQueryFromParamsJson(paramsJson)
465
627
  return buildString {
466
628
  append("https://")
467
- append(ASSET_HOST)
629
+ append(assetHost)
468
630
  append('/')
469
631
  // Serve the offline bundle from assets/<localBundle>/ (namespaced) rather
470
632
  // than the assets root, so it doesn't collide with other bundled assets