diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index acec61b0..1932a968 100644 --- a/native-modules/native-logger/package.json +++ b/native-modules/native-logger/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-logger", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json index 8a43f6e7..bf301cf5 100644 --- a/native-modules/react-native-aes-crypto/package.json +++ b/native-modules/react-native-aes-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-aes-crypto", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-aes-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index b8bb5473..9268f1d6 100644 --- a/native-modules/react-native-app-update/package.json +++ b/native-modules/react-native-app-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-app-update", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index b73eb98c..3cafb8be 100644 --- a/native-modules/react-native-async-storage/package.json +++ b/native-modules/react-native-async-storage/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-async-storage", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-async-storage", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index 1d36fc37..3982128e 100644 --- a/native-modules/react-native-background-thread/package.json +++ b/native-modules/react-native-background-thread/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-background-thread", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 138dcea9..9b63de3d 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 2e0192e7..3aed790b 100644 --- a/native-modules/react-native-bundle-update/package.json +++ b/native-modules/react-native-bundle-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-update", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-check-biometric-auth-changed/package.json b/native-modules/react-native-check-biometric-auth-changed/package.json index 711334eb..60ced134 100644 --- a/native-modules/react-native-check-biometric-auth-changed/package.json +++ b/native-modules/react-native-check-biometric-auth-changed/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-check-biometric-auth-changed", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json index 8f1ad821..5181669d 100644 --- a/native-modules/react-native-cloud-fs/package.json +++ b/native-modules/react-native-cloud-fs/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-fs", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-cloud-fs TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index e7beb378..6422a3cc 100644 --- a/native-modules/react-native-cloud-kit-module/package.json +++ b/native-modules/react-native-cloud-kit-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-kit-module", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-device-utils/android/src/main/java/com/margelo/nitro/reactnativedeviceutils/ReactNativeDeviceUtils.kt b/native-modules/react-native-device-utils/android/src/main/java/com/margelo/nitro/reactnativedeviceutils/ReactNativeDeviceUtils.kt index 7b9e9be4..b871ae6b 100644 --- a/native-modules/react-native-device-utils/android/src/main/java/com/margelo/nitro/reactnativedeviceutils/ReactNativeDeviceUtils.kt +++ b/native-modules/react-native-device-utils/android/src/main/java/com/margelo/nitro/reactnativedeviceutils/ReactNativeDeviceUtils.kt @@ -907,6 +907,13 @@ class ReactNativeDeviceUtils : HybridReactNativeDeviceUtilsSpec(), LifecycleEven } } + // iOS-only: cold-start LOCAL notification deep-link payload. Android delivers + // notification taps through the launching Intent's extras, a separate path, + // so there is nothing to hand back here. + override fun getAndClearColdStartLocalNotification(): Promise { + return Promise.resolved("") + } + // MARK: - ExitModule override fun exitApp() { diff --git a/native-modules/react-native-device-utils/ios/ReactNativeDeviceUtils.swift b/native-modules/react-native-device-utils/ios/ReactNativeDeviceUtils.swift index 0b142cea..620793f8 100644 --- a/native-modules/react-native-device-utils/ios/ReactNativeDeviceUtils.swift +++ b/native-modules/react-native-device-utils/ios/ReactNativeDeviceUtils.swift @@ -21,8 +21,26 @@ public class LaunchOptionsStore: NSObject { public var deviceToken: Data? public var startupTime: TimeInterval = 0 + // Cold-start deep-link: the JSON `userInfo` of a LOCAL notification the user + // tapped to launch the (killed) app. The legacy `NotificationCenter` broadcast + // in AppDelegate is fire-and-forget and is lost on cold start because JS has + // not registered a listener yet; this slot is a pull buffer JS drains once it + // boots. In-memory ONLY (no NSUserDefaults): a new process = fresh `nil`, so + // it is naturally launch-scoped and cannot replay a stale tap on a later + // unrelated cold start. AppDelegate writes it via KVC + // (`setValue(_:forKey:"coldStartLocalNotification")`), same bridge as the + // properties above. + public var coldStartLocalNotification: String? + private static let deviceTokenKey = "1k_device_token" + // Read-once: hand the payload to JS exactly once per launch. + public func takeColdStartLocalNotification() -> String { + let value = coldStartLocalNotification ?? "" + coldStartLocalNotification = nil + return value + } + public func getDeviceTokenString() -> String { // Prefer the JS-saved token (persisted across launches) if let saved = UserDefaults.standard.string(forKey: LaunchOptionsStore.deviceTokenKey), !saved.isEmpty { @@ -189,6 +207,12 @@ class ReactNativeDeviceUtils: HybridReactNativeDeviceUtilsSpec { } } + func getAndClearColdStartLocalNotification() throws -> Promise { + return Promise.async { + return LaunchOptionsStore.shared.takeColdStartLocalNotification() + } + } + // MARK: - ExitModule func exitApp() throws { diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index ed344740..72bf9922 100644 --- a/native-modules/react-native-device-utils/package.json +++ b/native-modules/react-native-device-utils/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-device-utils", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-device-utils/src/ReactNativeDeviceUtils.nitro.ts b/native-modules/react-native-device-utils/src/ReactNativeDeviceUtils.nitro.ts index 5c00916f..593522b2 100644 --- a/native-modules/react-native-device-utils/src/ReactNativeDeviceUtils.nitro.ts +++ b/native-modules/react-native-device-utils/src/ReactNativeDeviceUtils.nitro.ts @@ -59,6 +59,10 @@ export interface ReactNativeDeviceUtils saveDeviceToken(token: string): Promise; registerDeviceToken(): Promise; getStartupTime(): Promise; + // Returns the JSON userInfo of a LOCAL notification the user tapped to launch + // the (killed) app, then clears it so it is delivered exactly once. Empty + // string when there is none. iOS-only meaningful; Android returns "". + getAndClearColdStartLocalNotification(): Promise; // ExitModule exitApp(): void; diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json index 843361d6..42d19d9c 100644 --- a/native-modules/react-native-dns-lookup/package.json +++ b/native-modules/react-native-dns-lookup/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-dns-lookup", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-dns-lookup", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index 09d9ca21..3a895a5d 100644 --- a/native-modules/react-native-get-random-values/package.json +++ b/native-modules/react-native-get-random-values/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-get-random-values", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index ba7c2b37..bcd391d7 100644 --- a/native-modules/react-native-keychain-module/package.json +++ b/native-modules/react-native-keychain-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-keychain-module", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index b113f880..0108f7e3 100644 --- a/native-modules/react-native-lite-card/package.json +++ b/native-modules/react-native-lite-card/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-lite-card", - "version": "3.0.54", + "version": "3.0.56", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json index 1d3023cf..711205ef 100644 --- a/native-modules/react-native-network-info/package.json +++ b/native-modules/react-native-network-info/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-info", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index 198c2e99..3b62f5f4 100644 --- a/native-modules/react-native-pbkdf2/package.json +++ b/native-modules/react-native-pbkdf2/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pbkdf2", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-pbkdf2", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index b85dc3cf..da32f249 100644 --- a/native-modules/react-native-perf-memory/package.json +++ b/native-modules/react-native-perf-memory/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-memory", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-stats/package.json b/native-modules/react-native-perf-stats/package.json index 7a1bf4ee..290cb9d7 100644 --- a/native-modules/react-native-perf-stats/package.json +++ b/native-modules/react-native-perf-stats/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-stats", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-perf-stats", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json index 77625a03..94a7d90c 100644 --- a/native-modules/react-native-ping/package.json +++ b/native-modules/react-native-ping/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ping", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-ping TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 446db557..e655fee1 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index 7ad0e051..6a5c8d48 100644 --- a/native-modules/react-native-splash-screen/package.json +++ b/native-modules/react-native-splash-screen/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-splash-screen", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index 6726b259..34040312 100644 --- a/native-modules/react-native-split-bundle-loader/package.json +++ b/native-modules/react-native-split-bundle-loader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-split-bundle-loader", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index ebf0b69f..978b8f79 100644 --- a/native-modules/react-native-tcp-socket/package.json +++ b/native-modules/react-native-tcp-socket/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tcp-socket", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-tcp-socket", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json index 7fa553d2..a6366fc3 100644 --- a/native-modules/react-native-zip-archive/package.json +++ b/native-modules/react-native-zip-archive/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-zip-archive", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-zip-archive Nitro HybridObject for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index d4677fc9..686d0ab3 100644 --- a/native-views/react-native-auto-size-input/package.json +++ b/native-views/react-native-auto-size-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-auto-size-input", - "version": "3.0.54", + "version": "3.0.56", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-chart-webview/ChartWebview.podspec b/native-views/react-native-chart-webview/ChartWebview.podspec index 24f74b52..7527bb90 100644 --- a/native-views/react-native-chart-webview/ChartWebview.podspec +++ b/native-views/react-native-chart-webview/ChartWebview.podspec @@ -21,6 +21,9 @@ Pod::Spec.new do |s| s.dependency 'React-jsi' s.dependency 'React-callinvoker' + # DEBUG instrumentation: route native chart lifecycle logs into the shared + # OneKeyLog file (app-latest.log) via `import ReactNativeNativeLogger`. + s.dependency 'ReactNativeNativeLogger' load 'nitrogen/generated/ios/ChartWebview+autolinking.rb' add_nitrogen_files(s) diff --git a/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebview.kt b/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebview.kt index 7eb2934f..f30c5dce 100644 --- a/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebview.kt +++ b/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/ChartWebview.kt @@ -98,34 +98,37 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp // --- Source props --- + // Source/bridge setters call applySource (synchronous) — NOT scheduleReconcile, + // which caused an infinite reconcile loop (reconcile -> setSource -> prop + // re-apply -> reconcile ...). applySource sets warmDriver + warm-boots the page + // even when this host is not the visible owner, so post-attach bridge arrival + // still makes this host the warm driver (page->native callbacks fall back to it). private var _uri: String? = null override var uri: String? get() = _uri - set(value) { _uri = value; applySourceIfOwner() } + set(value) { _uri = value; applySource() } private var _localBundle: String? = null override var localBundle: String? get() = _localBundle - set(value) { _localBundle = value; applySourceIfOwner() } + set(value) { _localBundle = value; applySource() } private var _entry: String? = null override var entry: String? get() = _entry - set(value) { _entry = value; applySourceIfOwner() } + set(value) { _entry = value; applySource() } private var _paramsJson: String? = null override var paramsJson: String? get() = _paramsJson - set(value) { _paramsJson = value; applySourceIfOwner() } + set(value) { _paramsJson = value; applySource() } // Document-start bridge JS (single source of truth in the TS layer). Stored and // handed to the pooled WebView when we claim it, before its first load. private var _bridgeScript: String? = null override var bridgeScript: String? get() = _bridgeScript - // Re-apply the source: if this prop lands after the source props (a load can't - // run before the bridge is registered, so setSource deferred), this triggers it. - set(value) { _bridgeScript = value; applySourceIfOwner() } + set(value) { _bridgeScript = value; applySource() } // --- Singleton props --- // `pooled` + non-empty `reuseKey` => the backing WebView is shared (keyed by @@ -162,6 +165,22 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp scheduleReconcile() } + // --- Debugging --- + // Make the backing WebView inspectable via chrome://inspect. Driven by the + // app's "Enable Native Webview Debugging" dev-mode toggle, mirroring the main + // react-native-webview (which calls WebView.setWebContentsDebuggingEnabled). + // Stored and applied to the backing WebView both here (on prop change) and at + // host claim, since `backing` is null until the first reconcile assigns it. + // CAVEAT: setWebContentsDebuggingEnabled is PROCESS-GLOBAL — see + // PooledChartWebView.setInspectable. + private var _webviewDebuggingEnabled: Boolean? = null + override var webviewDebuggingEnabled: Boolean? + get() = _webviewDebuggingEnabled + set(value) { + _webviewDebuggingEnabled = value + backing?.setInspectable(value) + } + // --- Event callbacks --- override var onMessage: ((message: String) -> Unit)? = null override var onLoadEnd: (() -> Unit)? = null @@ -219,6 +238,9 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp val key = effectiveKey() val entry = ChartWebviewPool.acquireShared(key, context) backing = entry + // Apply this host's debug preference to the (possibly freshly created) backing + // WebView. Mirrors the main react-native-webview's setWebContentsDebuggingEnabled. + entry.setInspectable(_webviewDebuggingEnabled) // Refcount the pool entry once per host (reconcile runs many times). Balanced // by releaseShared in dispose(). If the reuseKey changed, release the old one. if (adoptedPoolKey != key) { @@ -226,13 +248,24 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp ChartWebviewPool.adopt(key) adoptedPoolKey = key } + // WARM-BOOT (mirror of iOS): boot the shared offline page + register as the + // warm DRIVER as soon as ANY referencing host has the bridge, even when not + // the visible owner (offscreen prewarm runs with attached=false/active=false). + // page->native callbacks fall back to warmDriver when there's no owner, so the + // bars-state / load-end signals aren't dropped during warm — otherwise the + // chart stays on the loading mask. Idempotent: setSource dedupes on same URL, + // setBridgeScript no-ops once registered. + val bs = _bridgeScript + if (!bs.isNullOrEmpty()) { + entry.setBridgeScript(bs) + entry.warmDriver = this + entry.setSource(_uri, _localBundle, _entry, _paramsJson) + } if (wantsOwnership()) { entry.owner = this - // Register the document-start bridge before the first load (the prop is set - // by now; the pool may have been created earlier by a window-attach reconcile). - entry.setBridgeScript(_bridgeScript ?: "") + // PERF: a host is now showing the chart — make sure the renderer is running. + entry.resume() entry.attachTo(container) - entry.setSource(_uri, _localBundle, _entry, _paramsJson) // Keep a fresh frame of OUR content while we own the WebView, so that when // we later go inactive (and the shared WebView reloads the other slot's // chart) our slot freezes to its own last frame, not the other content. @@ -248,6 +281,11 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp if (wasOwner) entry.owner = null stopOwnCapture() showPlaceholder(ownSnapshot) + // PERF: if nobody owns the shared WebView now, pause its renderer so it + // doesn't keep burning a CPU core + GPU + RAM in the background (Android + // doesn't auto-throttle offscreen WebViews like iOS does). Resumed on the + // next CLAIM. No-op until the page's first load completed. + entry.pauseIfIdle() } } @@ -257,17 +295,28 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp if (!wantsOwnership()) return val pooled = backing ?: PooledChartWebView.create(context, effectiveKey()) backing = pooled + pooled.setInspectable(_webviewDebuggingEnabled) pooled.owner = this pooled.setBridgeScript(_bridgeScript ?: "") pooled.attachTo(container) pooled.setSource(_uri, _localBundle, _entry, _paramsJson) } - private fun applySourceIfOwner() { + // Apply the source synchronously when a source/bridge prop changes. For a + // pooled host this ALSO registers it as the warmDriver and warm-boots the page + // even when it is not the visible owner, so page->native callbacks (bars-state / + // load-end) fall back to it when no host owns the pool. No scheduleReconcile — + // that looped. backing is null until the first reconcile assigns it (the + // reuseKey/pooled/active setters still scheduleReconcile), so warmDriver is set + // on the first prop change after the pool entry is acquired. + private fun applySource() { val pooled = backing ?: return - if (pooled.owner == this) { - // Register the bridge before any load setSource may trigger (setSource - // defers loading until the bridge is registered). + val bs = _bridgeScript + if (isPooled() && !bs.isNullOrEmpty()) { + pooled.setBridgeScript(bs) + pooled.warmDriver = this + pooled.setSource(_uri, _localBundle, _entry, _paramsJson) + } else if (pooled.owner == this) { pooled.setBridgeScript(_bridgeScript ?: "") pooled.setSource(_uri, _localBundle, _entry, _paramsJson) } @@ -359,7 +408,7 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp // WebView so it doesn't leak a Chromium renderer + JavascriptInterface per // mount/unmount. Pooled (shared) backing is intentionally left alive in the // warm pool — other hosts may still share it; its lifetime is the pool's. - fun dispose() { + override fun dispose() { stopOwnCapture() container.removeCallbacks(reconcileRunnable) container.removeCallbacks(revealFallbackRunnable) @@ -368,10 +417,19 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp if (pendingRevealHost == this) pendingRevealHost = null val entry = backing if (entry != null && entry.owner == this) entry.owner = null + if (entry != null && entry.warmDriver == this) entry.warmDriver = null // Balance the pool adopt() if this host ever joined a pooled key. Kept warm by // default (single-instance cache); the release path makes destroy() reachable. adoptedPoolKey?.let { ChartWebviewPool.releaseShared(it) } adoptedPoolKey = null + // Pooled host going away: release the shared WebView from THIS host's + // container, which is about to be dropped from the view tree. The pool keeps + // the WebView alive (warm) for the next host, but if we leave it parented to + // our dropped container the next host's attach can't clear that stale parent + // (removeView on a detached container isn't applied synchronously) and retries + // forever → blank/white chart. detachFrom is parent-checked, so if another host + // already re-claimed the WebView we leave it where it is. + if (entry != null && isPooled()) entry.detachFrom(container) // A private (non-pooled) instance is owned solely by this host — tear it down // so it doesn't leak a Chromium renderer + JavascriptInterface. if (entry != null && !isPooled()) entry.destroy() diff --git a/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/PooledChartWebView.kt b/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/PooledChartWebView.kt index a94d2d6c..333d3008 100644 --- a/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/PooledChartWebView.kt +++ b/native-views/react-native-chart-webview/android/src/main/java/com/margelo/nitro/chartwebview/PooledChartWebView.kt @@ -52,6 +52,9 @@ class PooledChartWebView private constructor( const val ASSET_HOST = "appassets.androidplatform.net" private const val DEFAULT_ENTRY = "index.html" + // Operational log tag for this pool (lifecycle + error paths only). + private const val TAG = "ChartWebviewPool" + // Tiny platform transport shim: defines the single hook the shared TS bridge // (CHART_BRIDGE_JS) calls. The bulk of the bridge lives in the TS layer and // arrives via the `bridgeScript` ctor arg — this is the only platform-specific @@ -148,7 +151,63 @@ class PooledChartWebView private constructor( } /** The host currently displaying this WebView; page events route here. */ - var owner: HybridChartWebview? = null + private var _owner: HybridChartWebview? = null + var owner: HybridChartWebview? + get() = _owner + set(value) { + _owner = value + } + // The host that warm-booted the page and can drive its symbol / receive its + // callbacks while there is no VISIBLE owner yet. Separate from `owner` (the + // YIELD path clears `owner`); callbacks fall back to this so bars-state / + // load-end aren't dropped during warm. Mirror of the iOS warmDriver. + private var _warmDriver: HybridChartWebview? = null + var warmDriver: HybridChartWebview? + get() = _warmDriver + set(value) { + _warmDriver = value + } + + // PERF (Android only): Android's in-process WebView/Chromium does NOT throttle + // an offscreen/unowned page (unlike iOS WKWebView). Left running, the warm + // pooled page keeps its rAF render loop + websockets + compositing alive + // FOREVER after the user leaves the chart — pinning a CPU core, growing RAM to + // OOM, and stealing the GPU from RN's RenderThread (every other screen stalls). + // We pause the WebView whenever no host owns it (after the first load) and + // resume on claim. Uses the PER-INSTANCE onPause()/onResume() — NOT the static + // pauseTimers()/resumeTimers(), which are process-global and would also freeze + // the app's other WebViews (inpage provider / web-embed / dapp browser). + private var paused = false + private var hasLoadedOnce = false + + fun markLoaded() { + hasLoadedOnce = true + } + + // Pause the renderer when nobody owns the shared WebView. onPause() stops + // drawing/compositing/animations (frees the GPU so navigation is smooth again) + // WITHOUT changing the view's visibility/attachment — we must NOT toggle + // visibility here, that left the WebView blank/white after re-claim. Skipped + // until the first load so we never freeze a booting page. Resumed (+ redraw) + // on the next CLAIM so the chart paints again. + fun pauseIfIdle() { + if (paused || !hasLoadedOnce || owner != null) { + return + } + paused = true + android.util.Log.i(TAG, "pool[$key] PAUSE (renderer idle)") + runOnUiThread { webView.onPause() } + } + + fun resume() { + if (!paused) return + paused = false + android.util.Log.i(TAG, "pool[$key] RESUME") + runOnUiThread { + webView.onResume() + webView.invalidate() + } + } private var assetLoader: WebViewAssetLoader? = null private var lastLoadedUrl: String? = null @@ -179,9 +238,28 @@ class PooledChartWebView private constructor( addJavascriptInterface(ChartBridge(), "AndroidChartBridge") } + // Apply the app's "Enable Native Webview Debugging" dev-mode toggle, mirroring + // how the main react-native-webview calls WebView.setWebContentsDebuggingEnabled. + // Called by the host both on the prop change and at host claim, so the toggle is + // honored even when this entry was created before the prop arrived. + // + // CAVEAT (PROCESS-GLOBAL): setWebContentsDebuggingEnabled is a STATIC method that + // flips remote-debugging for EVERY WebView in the whole process. Once any WebView + // (this chart, the main react-native-webview, etc.) enables it, it stays enabled + // process-wide until the app is killed — Android exposes no per-WebView toggle and + // no way to read the current value. So a null/false preference here cannot turn + // debugging back OFF once another WebView (or a prior true value) turned it ON; it + // simply does not re-enable it. We therefore only ever call the setter with `true` + // when explicitly enabled, leaving the process-global state untouched otherwise. + fun setInspectable(enabled: Boolean?) { + if (enabled == true) { + runOnUiThread { WebView.setWebContentsDebuggingEnabled(true) } + } + } + init { val n = liveCount.incrementAndGet() - android.util.Log.d("ChartWebviewPool", "WebView CREATED key=$key liveCount=$n") + android.util.Log.d(TAG, "WebView CREATED key=$key liveCount=$n") webView.webViewClient = object : WebViewClientCompat() { override fun shouldInterceptRequest( @@ -191,11 +269,12 @@ class PooledChartWebView private constructor( override fun onPageFinished(view: WebView, url: String?) { super.onPageFinished(view, url) + markLoaded() // The bridge is delivered exclusively via the origin-scoped document-start // script (see registerBridgeForOrigins). We deliberately do NOT re-inject // it here via evaluateJavascript — that ran on every page event regardless // of URL and would expose the privileged bridge to untrusted pages/frames. - owner?.dispatchLoadEnd() + (owner ?: warmDriver)?.dispatchLoadEnd() // Prime the snapshot so the first move already has a frame to mask with. refreshSnapshotSoon() } @@ -217,7 +296,26 @@ class PooledChartWebView private constructor( private inner class ChartBridge { @JavascriptInterface fun postMessage(message: String) { - runOnUiThread { owner?.dispatchMessage(message) } + runOnUiThread { + val target = owner ?: warmDriver + target?.dispatchMessage(message) + } + } + } + + // Robustly detach the WebView from [parent], even when [parent] is a disposed + // host container that is mid-teardown / detached from the window and holds the + // child in a transition / disappearing-children list — where a plain removeView() + // is deferred and leaves webView.parent set, stranding the shared WebView and + // blanking the next chart slot. endViewTransition() clears any pending transition + // hold; removeViewInLayout() is the in-layout fallback if the child is still held. + private fun forceDetach(parent: ViewGroup) { + if (webView.parent !== parent) return + try { parent.endViewTransition(webView) } catch (e: Throwable) {} + parent.removeView(webView) + if (webView.parent === parent) { + try { parent.removeViewInLayout(webView) } catch (e: Throwable) {} + parent.requestLayout() } } @@ -225,23 +323,30 @@ class PooledChartWebView private constructor( fun attachTo(container: ViewGroup) { val generation = attachGeneration.incrementAndGet() runOnUiThread { - attachToContainer(container, generation, canRetry = true) + attachToContainer(container, generation, retriesLeft = 12) } } - private fun attachToContainer(container: ViewGroup, generation: Int, canRetry: Boolean) { + private fun attachToContainer(container: ViewGroup, generation: Int, retriesLeft: Int) { if (generation != attachGeneration.get()) return if (webView.parent === container) return - (webView.parent as? ViewGroup)?.removeView(webView) + (webView.parent as? ViewGroup)?.let { forceDetach(it) } val currentParent = webView.parent - if (currentParent != null) { - if (canRetry) { - webView.post { attachToContainer(container, generation, canRetry = false) } + if (currentParent != null && currentParent !== container) { + // The old container still hasn't released the WebView (a disposed host's + // container mid-teardown / detached from window: removeView is deferred and + // getParent() stays set). Retry — but on the TARGET container's handler, + // which is attached to the window so its queue keeps draining, instead of the + // detached webView/old parent whose post() runnables may never run. + if (retriesLeft > 0) { + container.post { + attachToContainer(container, generation, retriesLeft = retriesLeft - 1) + } } else { android.util.Log.w( - "ChartWebviewPool", - "Skip attach key=$key because WebView parent was not cleared: $currentParent", + TAG, + "Skip attach key=$key after retries because WebView parent was not cleared: $currentParent", ) } return @@ -260,6 +365,27 @@ class PooledChartWebView private constructor( refreshSnapshotSoon() } + /** + * Remove the WebView from [container] ONLY if it is still parented there. + * + * Called on a pooled host's final teardown (dispose): that host's container is + * about to be dropped from the view tree, and a pooled host deliberately does + * NOT detach on the YIELD path (to avoid racing a rapid re-claim). Without this, + * the shared WebView stays parented to the dropped/detached container; the next + * host's [attachTo] then keeps hitting "old parent not cleared" (removeView on a + * detached container isn't applied synchronously) and retries forever — leaving + * the new chart slot blank/white. The `parent === container` guard makes this + * safe against ordering: if a new host has already re-claimed and reparented the + * WebView, we must NOT rip it back off, so we only remove when WE still hold it. + */ + fun detachFrom(container: ViewGroup) { + runOnUiThread { + if (webView.parent === container) { + forceDetach(container) + } + } + } + /** Remove the WebView from its current parent (keeps it alive, warm). */ fun detachFromParent() { runOnUiThread { @@ -437,7 +563,7 @@ class PooledChartWebView private constructor( (webView.parent as? ViewGroup)?.removeView(webView) webView.destroy() val n = liveCount.decrementAndGet() - android.util.Log.d("ChartWebviewPool", "WebView DESTROYED key=$key liveCount=$n") + android.util.Log.d(TAG, "WebView DESTROYED key=$key liveCount=$n") } } diff --git a/native-views/react-native-chart-webview/ios/ChartWebview.swift b/native-views/react-native-chart-webview/ios/ChartWebview.swift index 0e9faf9d..bcd0423d 100644 --- a/native-views/react-native-chart-webview/ios/ChartWebview.swift +++ b/native-views/react-native-chart-webview/ios/ChartWebview.swift @@ -1,6 +1,7 @@ import Foundation import UIKit import WebKit +import ReactNativeNativeLogger // MARK: - Constants (shared) @@ -13,6 +14,25 @@ private enum ChartWebviewConst { static let messageHandlerName = "onekeyChart" } +// MARK: - Diagnostic logging (DEBUG instrumentation) +// +// Routes the chart-webview native lifecycle into the shared OneKeyLog file +// (app-latest.log) so the offline / prewarm / pool-ownership chain is visible +// alongside the JS `market => chart => *` events. Tag is "ChartWV". Raw NSLog +// is NOT enough — it never reaches app-latest.log, which is why the native side +// was previously invisible during log analysis. Truncates payloads to keep the +// file readable and avoid leaking message bodies. +enum ChartWVLog { + static func i(_ msg: String) { OneKeyLog.info("ChartWV", msg) } + static func w(_ msg: String) { OneKeyLog.warn("ChartWV", msg) } + static func e(_ msg: String) { OneKeyLog.error("ChartWV", msg) } + /// Truncate a (possibly large / sensitive) payload for logging. + static func clip(_ s: String?, _ n: Int = 120) -> String { + guard let s = s else { return "nil" } + return s.count <= n ? s : String(s.prefix(n)) + "…(\(s.count))" + } +} + // MARK: - ChartContainerView (window-attach detection) /// The host's `view`. Reports when it is attached to / detached from a window so @@ -69,9 +89,12 @@ class HybridChartWebview: HybridChartWebviewSpec { override init() { super.init() + ChartWVLog.i("host.init id=\(instanceId)") container.onWindowChange = { [weak self] attached in - self?.attached = attached - self?.scheduleReconcile() + guard let self = self else { return } + ChartWVLog.i("host.windowChange id=\(self.instanceId) attached=\(attached) reuseKey=\(self.reuseKey ?? "nil") pooled=\(String(describing: self.pooled)) active=\(String(describing: self.active))") + self.attached = attached + self.scheduleReconcile() } } @@ -97,15 +120,19 @@ class HybridChartWebview: HybridChartWebviewSpec { // MARK: - Props (source) - var uri: String? { didSet { applySourceIfOwner() } } - var localBundle: String? { didSet { applySourceIfOwner() } } - var entry: String? { didSet { applySourceIfOwner() } } - var paramsJson: String? { didSet { applySourceIfOwner() } } + // Source/bridge setters call applySource (synchronous) — NOT scheduleReconcile, + // which loops (reconcile -> setSource -> prop re-apply -> reconcile ...). + // applySource sets warmDriver + warm-boots the page even when this host is not + // the visible owner, so a bridge prop arriving after the window-attach reconcile + // still makes this host the warm driver (page->native callbacks fall back to it). + var uri: String? { didSet { applySource() } } + var localBundle: String? { didSet { applySource() } } + var entry: String? { didSet { applySource() } } + var paramsJson: String? { didSet { applySource() } } // Document-start bridge JS (single source of truth in the TS layer). Handed to - // the pooled WebView when we claim it, before its first load. Re-applying the - // source on change triggers a deferred load if this prop lands after the source. - var bridgeScript: String? { didSet { applySourceIfOwner() } } + // the pooled WebView when we claim it, before its first load. + var bridgeScript: String? { didSet { applySource() } } // MARK: - Props (singleton) @@ -132,6 +159,20 @@ class HybridChartWebview: HybridChartWebviewSpec { } } + // MARK: - Props (debugging) + + // Make the backing WKWebView inspectable (Safari Web Inspector). Driven by the + // app's "Enable Native Webview Debugging" dev-mode toggle, mirroring the main + // react-native-webview (which sets WKWebView.isInspectable). nil => default to + // the DEBUG build behavior (see PooledChartWebView.applyInspectable). Applied to + // the backing WebView both here (on prop change) and at WebView creation, since + // `backing` is nil until the first reconcile assigns it. + var webviewDebuggingEnabled: Bool? { + didSet { + backing?.setInspectable(webviewDebuggingEnabled) + } + } + // MARK: - Props (events) var onMessage: ((_ message: String) -> Void)? @@ -140,6 +181,10 @@ class HybridChartWebview: HybridChartWebviewSpec { // Called by the backing PooledChartWebView while this host is the owner. func handleMessage(_ message: String) { + // page -> native. Truncated; OneKeyLog rate-limits/dedups repeats so streaming + // data won't flood. Key for Q2 (market chart no data): shows the page's + // $private kline requests and whether replies come back. + ChartWVLog.i("msg.in id=\(instanceId) \(ChartWVLog.clip(message, 200))") // The chart reports it has painted the new symbol after a switch; that's our // cue to drop the snapshot we held over the switch and reveal the live chart. if message.contains(HybridChartWebview.renderReadyMarker) { onContentRendered() } @@ -167,6 +212,25 @@ class HybridChartWebview: HybridChartWebviewSpec { private func wantsOwnership() -> Bool { attached && (active != false) } + // Apply the source synchronously on a source/bridge prop change. For a pooled + // host this ALSO registers it as the warmDriver and warm-boots the page even + // when it is not the visible owner, so page->native callbacks (bars-state / + // load-end) fall back to it when no host owns the pool. No scheduleReconcile — + // that loops. `backing` is nil until the first reconcile assigns it (reuseKey / + // pooled / active setters still scheduleReconcile), so warmDriver is set on the + // first prop change after the pool entry is acquired. + private func applySource() { + guard let pooled = backing else { return } + if isPooled(), let bs = bridgeScript, !bs.isEmpty { + pooled.setBridgeScript(bs) + pooled.warmDriver = self + pooled.setSource(uri: uri, localBundle: localBundle, entry: entry, paramsJson: paramsJson) + } else if pooled.owner === self { + pooled.setBridgeScript(bridgeScript ?? "") + pooled.setSource(uri: uri, localBundle: localBundle, entry: entry, paramsJson: paramsJson) + } + } + // A single React commit applies props one at a time, so the intermediate // states are inconsistent (e.g. `pooled` re-applied while `active` is still // stale). Reacting to each setter synchronously makes the wrong host claim @@ -194,6 +258,9 @@ class HybridChartWebview: HybridChartWebviewSpec { let key = effectiveKey() let pooled = ChartWebviewPool.shared.acquireShared(key: key) backing = pooled + // Apply this host's debug preference to the (possibly freshly created) backing + // WebView. Mirrors the main react-native-webview toggling WKWebView.isInspectable. + pooled.setInspectable(webviewDebuggingEnabled) // Refcount the entry once per host (reconcile runs many times). Balanced by // releaseShared in deinit. If the reuseKey changed, release the old one first. if adoptedPoolKey != key { @@ -201,13 +268,36 @@ class HybridChartWebview: HybridChartWebviewSpec { ChartWebviewPool.shared.adopt(key: key) adoptedPoolKey = key } + ChartWVLog.i("reconcilePooled id=\(instanceId) key=\(key) wantsOwnership=\(wantsOwnership()) (attached=\(attached) active=\(String(describing: active))) bridgeLen=\((bridgeScript ?? "").count) localBundle=\(localBundle ?? "nil") entry=\(entry ?? "nil")") + + // Q1 FIX: WARM-BOOT the shared offline page as soon as ANY referencing host + // has the document-start bridge + a source — even when this host is NOT the + // visible owner (the offscreen prewarm runs with attached=false/active=false, + // so the old code, which only loaded inside `wantsOwnership()`, never booted + // the page until a real chart screen attached+focused). LOAD is now decoupled + // from view ownership: ownership below still governs attach / reveal / snapshot. + // Idempotent across hosts — the unified URL is constant (setSource logs + // SAME_URL), and setBridgeScript no-ops once registered. + if let bs = bridgeScript, !bs.isEmpty { + ChartWVLog.i("reconcilePooled.WARM id=\(instanceId) key=\(key) (owner-independent boot; wantsOwnership=\(wantsOwnership()))") + pooled.setBridgeScript(bs) + // Q1 FIX (data): register as the warm DRIVER (NOT owner — the YIELD branch + // below clears `owner`, which is why the previous provisional-owner attempt + // was wiped in the same reconcile). didFinish / page messages fall back to + // warmDriver when there's no visible owner, so the page is driven its symbol + // the instant it loads instead of waiting ~5s for a focused host to claim. + pooled.warmDriver = self + ChartWVLog.i("reconcilePooled.WARM_DRIVER id=\(instanceId) key=\(key)") + // setSource internally guards (bridgeRegistered / SAME_URL / NO_URL), so this + // is idempotent and a no-op when there is nothing new to load. + pooled.setSource(uri: uri, localBundle: localBundle, entry: entry, paramsJson: paramsJson) + } + if wantsOwnership() { + let wasOwner = pooled.owner === self pooled.owner = self - // Register the document-start bridge before the first load (the prop is set - // by now; the pool may have been created earlier by a window-attach reconcile). - pooled.setBridgeScript(bridgeScript ?? "") + ChartWVLog.i("reconcilePooled.CLAIM id=\(instanceId) key=\(key) wasOwner=\(wasOwner)") pooled.attach(to: container) - pooled.setSource(uri: uri, localBundle: localBundle, entry: entry, paramsJson: paramsJson) // Keep a fresh frame of OUR content while we own the WebView, so that when // we later go inactive (and the shared WebView reloads the other slot's // chart) our slot freezes to its own last frame, not the other content. @@ -224,6 +314,7 @@ class HybridChartWebview: HybridChartWebviewSpec { // Inactive: give up ownership only if we still hold it, and freeze to our // own last captured frame. We do NOT detach (that races a rapid re-claim). let wasOwner = pooled.owner === self + ChartWVLog.i("reconcilePooled.YIELD id=\(instanceId) key=\(key) wasOwner=\(wasOwner) (attached=\(attached) active=\(String(describing: active)))") if wasOwner { pooled.owner = nil } stopOwnCapture() showPlaceholder(ownSnapshot) @@ -262,19 +353,13 @@ class HybridChartWebview: HybridChartWebviewSpec { guard wantsOwnership() else { return } let pooled = backing ?? PooledChartWebView(key: effectiveKey()) backing = pooled + pooled.setInspectable(webviewDebuggingEnabled) pooled.owner = self pooled.setBridgeScript(bridgeScript ?? "") pooled.attach(to: container) pooled.setSource(uri: uri, localBundle: localBundle, entry: entry, paramsJson: paramsJson) } - private func applySourceIfOwner() { - guard let pooled = backing, pooled.owner === self else { return } - // Register the bridge before any load setSource may trigger (setSource defers - // loading until the bridge is registered). - pooled.setBridgeScript(bridgeScript ?? "") - pooled.setSource(uri: uri, localBundle: localBundle, entry: entry, paramsJson: paramsJson) - } // MARK: - Snapshot placeholder (shown while this host is inactive) @@ -352,6 +437,13 @@ final class PooledChartWebView { let key: String weak var owner: HybridChartWebview? + // Q1 FIX (data): the host that warm-booted the page and can drive its symbol / + // service its callbacks while there is no VISIBLE owner yet. Separate from + // `owner` because the reconcile YIELD branch clears `owner` to nil (so a + // provisional owner never survived to nav.didFinish). Callbacks fall back to + // this when `owner` is nil, so the page is told its symbol the instant it loads + // instead of waiting ~5s for a focused host to attach/claim. + weak var warmDriver: HybridChartWebview? // The page's user-content controller, kept so the document-start bridge can be // added lazily (see setBridgeScript) once the host has the prop value. @@ -366,6 +458,11 @@ final class PooledChartWebView { private var lastLoadedUrl: String? private var attachGeneration = 0 + // Whether the backing WKWebView should be inspectable (Safari Web Inspector). + // nil until a host applies its debug preference; defaults to the DEBUG build + // behavior (see resolvedInspectable), mirroring the main react-native-webview. + private var inspectablePreference: Bool? + /// Read by the scheme handler to resolve offline files. fileprivate var currentLocalBundle: String? @@ -379,7 +476,7 @@ final class PooledChartWebView { init(key: String) { self.key = key PooledChartWebView.liveCount += 1 - NSLog("[ChartWebviewPool] WebView CREATED key=\(key) liveCount=\(PooledChartWebView.liveCount)") + ChartWVLog.i("pool.CREATE key=\(key) liveCount=\(PooledChartWebView.liveCount)") setupWebView() } @@ -388,7 +485,10 @@ final class PooledChartWebView { // subsequent calls are no-ops. Done lazily because the first reconcile // (window-attach) can run before the bridgeScript prop is applied. func setBridgeScript(_ bridgeScript: String) { - guard !bridgeScript.isEmpty, let userContent = userContent else { return } + guard !bridgeScript.isEmpty, let userContent = userContent else { + ChartWVLog.w("setBridgeScript.SKIP key=\(key) empty=\(bridgeScript.isEmpty) hasUserContent=\(userContent != nil) -> bridgeRegistered stays \(bridgeRegistered)") + return + } // Re-register if a second host sharing the reuseKey supplies a DIFFERENT script // instead of silently dropping it (fix #4). In the app's single-reuseKey + // constant-bridge reality this never fires; not latching keeps it correct. @@ -399,6 +499,7 @@ final class PooledChartWebView { } bridgeRegistered = true registeredBridgeScript = bridgeScript + ChartWVLog.i("setBridgeScript.REGISTERED key=\(key) len=\(bridgeScript.count)") let handlerName = ChartWebviewConst.messageHandlerName let shim = "(function(){window.__chartNativePost=function(s){" + "window.webkit.messageHandlers.\(handlerName).postMessage(s);};})();" @@ -442,10 +543,37 @@ final class PooledChartWebView { webView.navigationDelegate = proxy webView.uiDelegate = proxy webView.scrollView.bounces = false + self.webView = webView + // Honor the app's dev-mode webview-debug toggle instead of an unconditional + // `true`. nil (no preference applied yet) falls back to the DEBUG build default. + applyInspectable() + } + + // Set the host-driven debug preference and apply it to the live WebView. Called + // both on the prop change and at host claim, so the (possibly newly created) + // WebView always reflects the latest toggle value. + func setInspectable(_ enabled: Bool?) { + inspectablePreference = enabled + applyInspectable() + } + + // Resolve the effective inspectable value: explicit preference if set, otherwise + // the DEBUG build default (mirrors the main react-native-webview, which is + // inspectable in dev builds even without the toggle). + private func resolvedInspectable() -> Bool { + if let pref = inspectablePreference { return pref } + #if DEBUG + return true + #else + return false + #endif + } + + private func applyInspectable() { + guard let webView = webView else { return } if #available(iOS 16.4, *) { - webView.isInspectable = true + webView.isInspectable = resolvedInspectable() } - self.webView = webView } // MARK: - Reparenting @@ -498,7 +626,7 @@ final class PooledChartWebView { self.userContent = nil self.webView = nil PooledChartWebView.liveCount -= 1 - NSLog("[ChartWebviewPool] WebView DESTROYED key=\(self.key) liveCount=\(PooledChartWebView.liveCount)") + ChartWVLog.i("pool.DESTROY key=\(self.key) liveCount=\(PooledChartWebView.liveCount)") } } @@ -570,15 +698,26 @@ final class PooledChartWebView { // Never load before the document-start bridge is registered — otherwise the // page boots without the bridge and its first $private requests are lost. The // host re-calls setSource once the bridgeScript prop arrives. - guard bridgeRegistered else { return } + guard bridgeRegistered else { + ChartWVLog.w("setSource.BLOCKED key=\(key) bridgeRegistered=false -> NOT loading (will retry when bridgeScript arrives). uri=\(ChartWVLog.clip(uri)) localBundle=\(localBundle ?? "nil")") + return + } currentLocalBundle = localBundle - guard let urlString = computeTargetUrl(uri: uri, localBundle: localBundle, entry: entry, paramsJson: paramsJson) else { return } - guard urlString != lastLoadedUrl else { return } + guard let urlString = computeTargetUrl(uri: uri, localBundle: localBundle, entry: entry, paramsJson: paramsJson) else { + ChartWVLog.w("setSource.NO_URL key=\(key) uri=\(ChartWVLog.clip(uri)) localBundle=\(localBundle ?? "nil") entry=\(entry ?? "nil") -> nothing to load") + return + } + guard urlString != lastLoadedUrl else { + ChartWVLog.i("setSource.SAME_URL key=\(key) skip reload url=\(ChartWVLog.clip(urlString))") + return + } guard let url = URL(string: urlString) else { + ChartWVLog.e("setSource.INVALID_URL key=\(key) url=\(ChartWVLog.clip(urlString))") owner?.handleError("Invalid url: \(urlString)") return } lastLoadedUrl = urlString + ChartWVLog.i("setSource.LOAD key=\(key) url=\(ChartWVLog.clip(urlString))") runOnMain { [weak self] in self?.webView?.load(URLRequest(url: url)) } } @@ -622,8 +761,12 @@ final class PooledChartWebView { // MARK: - Bridge methods func postMessage(_ message: String) { + ChartWVLog.i("msg.out key=\(key) \(ChartWVLog.clip(message, 200))") runOnMain { [weak self] in - guard let self = self, let webView = self.webView else { return } + guard let self = self, let webView = self.webView else { + ChartWVLog.w("msg.out.DROPPED no webView") + return + } let jsStringLiteral = self.jsStringLiteral(from: message) let js = "window.postMessage(JSON.parse(\(jsStringLiteral)), '*')" webView.evaluateJavaScript(js, completionHandler: nil) @@ -677,7 +820,9 @@ extension ChartWebViewProxy: WKScriptMessageHandler { didReceive message: WKScriptMessage ) { guard message.name == ChartWebviewConst.messageHandlerName else { return } - let owner = pooled?.owner + // Q1 FIX: route page->native messages ($private data requests) to the warm + // driver when there's no visible owner, so they aren't dropped during warm. + let owner = pooled?.owner ?? pooled?.warmDriver if let body = message.body as? String { owner?.handleMessage(body) } else if let data = try? JSONSerialization.data(withJSONObject: message.body, options: []), @@ -693,16 +838,22 @@ extension ChartWebViewProxy: WKScriptMessageHandler { extension ChartWebViewProxy: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - pooled?.owner?.handleLoadEnd() + // Q1 FIX: fall back to warmDriver when there's no visible owner yet, so the + // page's load-complete callback (-> JS onLoadEnd -> SYMBOL_CHANGE) fires now. + let target = pooled?.owner ?? pooled?.warmDriver + ChartWVLog.i("nav.didFinish url=\(ChartWVLog.clip(webView.url?.absoluteString)) hasOwner=\(pooled?.owner != nil) viaWarmDriver=\(pooled?.owner == nil && pooled?.warmDriver != nil)") + target?.handleLoadEnd() // Prime the snapshot so the first move already has a frame to mask with. pooled?.refreshSnapshotSoon() } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + ChartWVLog.e("nav.didFail url=\(ChartWVLog.clip(webView.url?.absoluteString)) error=\(error.localizedDescription)") pooled?.owner?.handleError(error.localizedDescription) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + ChartWVLog.e("nav.didFailProvisional url=\(ChartWVLog.clip(webView.url?.absoluteString)) error=\(error.localizedDescription)") pooled?.owner?.handleError(error.localizedDescription) } } @@ -723,6 +874,7 @@ extension ChartWebViewProxy: WKURLSchemeHandler { } guard let localBundle = pooled?.currentLocalBundle, !localBundle.isEmpty else { + ChartWVLog.e("scheme.NO_BUNDLE url=\(ChartWVLog.clip(url.absoluteString)) -> 404 (currentLocalBundle empty)") respondNotFound(url: url, task: urlSchemeTask) return } @@ -733,9 +885,11 @@ extension ChartWebViewProxy: WKURLSchemeHandler { guard let fileURL = resolveBundleFileURL(localBundle: localBundle, relativePath: relativePath), let data = try? Data(contentsOf: fileURL) else { + ChartWVLog.e("scheme.404 bundle=\(localBundle) path=\(relativePath) (resolved=\(resolveBundleFileURL(localBundle: localBundle, relativePath: relativePath)?.path ?? "nil")) -> Not Found") respondNotFound(url: url, task: urlSchemeTask) return } + ChartWVLog.i("scheme.serve bundle=\(localBundle) path=\(relativePath) bytes=\(data.count)") let headers: [String: String] = [ "Content-Type": mimeTypeForPath(fileURL.pathExtension), diff --git a/native-views/react-native-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 55e54f2b..2ad67c43 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-chart-webview", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-chart-webview/src/ChartWebview.nitro.ts b/native-views/react-native-chart-webview/src/ChartWebview.nitro.ts index 570345aa..508537f2 100644 --- a/native-views/react-native-chart-webview/src/ChartWebview.nitro.ts +++ b/native-views/react-native-chart-webview/src/ChartWebview.nitro.ts @@ -39,6 +39,16 @@ export interface ChartWebviewProps extends HybridViewProps { // hardcodes `setIsActive`/`getIsActive`, causing a NoSuchMethodError at runtime. active?: boolean; + // --- Debugging --- + // Make the backing WebView inspectable (Safari Web Inspector on iOS, + // chrome://inspect on Android). Driven by the app's "Enable Native Webview + // Debugging" dev-mode toggle, mirroring how the main react-native-webview + // honors it. When omitted, the native side defaults to its dev-build default + // (iOS DEBUG / Android leaves it off). NOTE (Android): the underlying call + // WebView.setWebContentsDebuggingEnabled is PROCESS-GLOBAL — once any WebView + // enables it, it stays enabled process-wide until the app is killed. + webviewDebuggingEnabled?: boolean; + // --- Events --- // page -> JS, raw JSON string (the JS layer parses the $private payload). onMessage?: (message: string) => void; diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index 6b7d5cba..7831366d 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pager-view", - "version": "3.0.54", + "version": "3.0.56", "description": "React Native wrapper for Android and iOS ViewPager", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index 9d15bebe..c988d25c 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index b5f3f2e9..cc2b585a 100644 --- a/native-views/react-native-scroll-guard/package.json +++ b/native-views/react-native-scroll-guard/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-scroll-guard", - "version": "3.0.54", + "version": "3.0.56", "description": "A native view wrapper that prevents parent scrollable containers (PagerView/ViewPager2) from intercepting child scroll gestures", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index 3cc13ca5..7132e33a 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-segment-slider", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-skeleton/package.json b/native-views/react-native-skeleton/package.json index 647f8c9c..80a44605 100644 --- a/native-views/react-native-skeleton/package.json +++ b/native-views/react-native-skeleton/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-skeleton", - "version": "3.0.54", + "version": "3.0.56", "description": "react-native-skeleton", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-tab-view/package.json b/native-views/react-native-tab-view/package.json index 59028f00..d3878b3e 100644 --- a/native-views/react-native-tab-view/package.json +++ b/native-views/react-native-tab-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tab-view", - "version": "3.0.54", + "version": "3.0.56", "description": "Native Bottom Tabs for React Native (UIKit implementation)", "source": "./src/index.tsx", "main": "./lib/module/index.js",