Skip to content

Commit dd2dae3

Browse files
committed
fix(wasmJs): fix JS bridge double dispatch, listener leak, and simplify state management
- Remove duplicate message handler registration in WasmJsWebView.injectJsBridge() that caused every JS bridge message to be dispatched twice - Keep single handler in setupJsBridgeForWasm() with strict JSON regex parsing and proper param unescaping - Add removeEventListener cleanup on dispose to prevent memory leaks - Remove unnecessary WasmJsWebViewState intermediate class and WebViewStateAdapter, sync HtmlViewState directly to WebViewState - Replace polling loop (while/delay 100ms) with reactive snapshotFlow for navigation state sync - Simulate loading progress (0.1→0.9) like desktop platform since iframe doesn't expose real progress - Add onLoadStarted callback so direct loadUrl/loadHtml/reload calls properly trigger loading state - Add comments explaining canGoForward=false browser limitation
1 parent db4ed5a commit dd2dae3

4 files changed

Lines changed: 56 additions & 159 deletions

File tree

webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WasmJsWebView.kt

Lines changed: 9 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
@file:OptIn(ExperimentalWasmJsInterop::class)
22
package io.github.kdroidfilter.webview.web
33

4-
import io.github.kdroidfilter.webview.jsbridge.JsMessage
54
import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge
65
import io.github.kdroidfilter.webview.util.KLogger
76
import kotlinx.coroutines.CoroutineScope
@@ -24,19 +23,23 @@ class WasmJsWebView(
2423
private val element: HTMLIFrameElement,
2524
override val nativeWebView: NativeWebView,
2625
override val scope: CoroutineScope,
27-
override val webViewJsBridge: WebViewJsBridge?
26+
override val webViewJsBridge: WebViewJsBridge?,
27+
var onLoadStarted: (() -> Unit)? = null,
2828
) : IWebView {
2929
override fun canGoBack(): Boolean = element.contentWindow?.history?.length?.let {
3030
it > 1
3131
} ?: false
3232

33+
// Browser iframe history API does not expose whether forward navigation is available.
34+
// history.length only gives total entries, not the current position within the stack.
3335
override fun canGoForward(): Boolean = false
3436

3537
override fun loadUrl(
3638
url: String,
3739
additionalHttpHeaders: Map<String, String>
3840
) {
3941
try {
42+
onLoadStarted?.invoke()
4043
element.src = url
4144
if (webViewJsBridge != null) {
4245
scope.launch {
@@ -63,6 +66,7 @@ class WasmJsWebView(
6366
) {
6467
try {
6568
if (html != null) {
69+
onLoadStarted?.invoke()
6670
val htmlWithBridge = if (webViewJsBridge != null) {
6771
injectBridgeIntoHtml(
6872
htmlContent = html,
@@ -92,6 +96,7 @@ class WasmJsWebView(
9296
WebViewFileReadType.ASSET_RESOURCES -> "assets/$fileName"
9397
WebViewFileReadType.COMPOSE_RESOURCE_FILES -> fileName
9498
}
99+
onLoadStarted?.invoke()
95100
element.src = url
96101

97102
if (webViewJsBridge != null) {
@@ -148,6 +153,7 @@ class WasmJsWebView(
148153

149154
override fun reload() {
150155
try {
156+
onLoadStarted?.invoke()
151157
element.contentWindow?.location?.reload()
152158
} catch (e: Exception) {
153159
KLogger.e(
@@ -192,49 +198,7 @@ class WasmJsWebView(
192198

193199
val bridgeScript = createJsBridgeScript(webViewJsBridge.jsBridgeName, true)
194200
evaluateJavaScript(bridgeScript)
195-
196-
val messageHandler: (org.w3c.dom.events.Event) -> Unit = { event ->
197-
val messageEvent = event as org.w3c.dom.MessageEvent
198-
val iframe = element
199-
200-
if (
201-
messageEvent.source == iframe.contentWindow &&
202-
messageEvent.data != null
203-
) {
204-
try {
205-
val dataString = messageEvent.data.toString()
206-
207-
if (dataString.contains(webViewJsBridge.jsBridgeName)) {
208-
val actionPattern = """action[=:][\s]*['"](.*?)['"]""".toRegex()
209-
val paramsPattern = """params[=:][\s]*['"](.*?)['"]""".toRegex()
210-
val callbackPattern = """callbackId[=:][\s]*(\d+)""".toRegex()
211-
212-
val action = actionPattern.find(dataString)?.groupValues?.get(1)
213-
val params = paramsPattern.find(dataString)?.groupValues?.get(1) ?: "{}"
214-
val callbackId =
215-
callbackPattern
216-
.find(dataString)
217-
?.groupValues
218-
?.get(1)
219-
?.toIntOrNull()
220-
?: 0
221-
222-
if (action != null) {
223-
val message = JsMessage(
224-
callbackId = callbackId,
225-
methodName = action,
226-
params = params,
227-
)
228-
webViewJsBridge.dispatch(message)
229-
}
230-
}
231-
} catch (_: Exception) {
232-
}
233-
}
234-
}
235-
236-
kotlinx.browser.window.addEventListener("message", messageHandler)
237-
webViewJsBridge.webView = this
201+
// Message handling is done by the single listener registered in setupJsBridgeForWasm()
238202
}
239203

240204
override fun initJsBridge(webViewJsBridge: WebViewJsBridge) {

webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WasmJsWebViewNavigator.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ class HtmlViewNavigator(
110110
canGoBack = element.contentWindow?.history?.length?.let {
111111
it > 1
112112
} ?: false
113+
// Browser iframe history API does not expose forward availability
113114
canGoForward = false
114115
} catch (e: Exception) {
115116
KLogger.e(

webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebView.wasmJs.kt

Lines changed: 46 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ package io.github.kdroidfilter.webview.web
44

55
import androidx.compose.runtime.Composable
66
import androidx.compose.runtime.LaunchedEffect
7+
import androidx.compose.runtime.mutableStateOf
78
import androidx.compose.runtime.remember
89
import androidx.compose.runtime.rememberCoroutineScope
10+
import androidx.compose.runtime.snapshotFlow
911
import androidx.compose.ui.Modifier
1012
import io.github.kdroidfilter.webview.jsbridge.JsMessage
1113
import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge
@@ -86,71 +88,6 @@ fun createWebViewWithSettings(
8688
return NativeWebView(iframe)
8789
}
8890

89-
/**
90-
* Simple state adapter for WebView state synchronization
91-
*/
92-
@Composable
93-
internal fun rememberWebViewStateAdapter(
94-
commonWebViewState: WebViewState
95-
): WebViewStateAdapter = remember(commonWebViewState) {
96-
WebViewStateAdapter(commonWebViewState)
97-
}
98-
99-
internal class WebViewStateAdapter(
100-
private val commonWebViewState: WebViewState,
101-
private val wasmWebViewState: WasmJsWebViewState = WasmJsWebViewState(),
102-
) {
103-
fun syncFromCommon() {
104-
when (val content = commonWebViewState.content) {
105-
is WebContent.Url -> {
106-
wasmWebViewState.url = content.url
107-
wasmWebViewState.content = ""
108-
}
109-
110-
is WebContent.Data -> {
111-
wasmWebViewState.content = content.data
112-
wasmWebViewState.url = ""
113-
}
114-
115-
is WebContent.File -> {
116-
wasmWebViewState.content = ""
117-
wasmWebViewState.url = ""
118-
}
119-
120-
is WebContent.NavigatorOnly -> {
121-
wasmWebViewState.url = ""
122-
wasmWebViewState.content = ""
123-
}
124-
}
125-
126-
commonWebViewState.lastLoadedUrl?.let {
127-
wasmWebViewState.lastLoadedUrl = it
128-
}
129-
130-
commonWebViewState.pageTitle?.let {
131-
wasmWebViewState.pageTitle = it
132-
}
133-
}
134-
135-
fun syncToCommon() {
136-
wasmWebViewState.lastLoadedUrl?.let {
137-
commonWebViewState.lastLoadedUrl = it
138-
}
139-
140-
wasmWebViewState.pageTitle?.let {
141-
commonWebViewState.pageTitle = it
142-
}
143-
144-
if (wasmWebViewState.isLoading) {
145-
commonWebViewState.loadingState = LoadingState.Loading(0f)
146-
} else {
147-
commonWebViewState.loadingState = LoadingState.Finished
148-
}
149-
}
150-
151-
fun getWasmWebViewState(): WasmJsWebViewState = wasmWebViewState
152-
}
153-
15491
/**
15592
* Implementation of the WebView composable for the WebAssembly/JavaScript platform.
15693
*/
@@ -165,23 +102,20 @@ actual fun ActualWebView(
165102
factory: (WebViewFactoryParam) -> NativeWebView
166103
) {
167104
val scope = rememberCoroutineScope()
168-
val stateAdapter = rememberWebViewStateAdapter(state)
169105
val htmlNavigator = rememberHtmlViewNavigator()
170106
val htmlViewState = remember { HtmlViewState() }
107+
val bridgeCleanup = remember { mutableStateOf<(() -> Unit)?>(null) }
171108

109+
// Reactively sync navigation state from htmlNavigator to navigator
172110
LaunchedEffect(navigator, htmlNavigator) {
173-
scope.launch {
174-
while (true) {
175-
kotlinx.coroutines.delay(100)
176-
navigator.canGoBack = htmlNavigator.canGoBack
177-
navigator.canGoForward = htmlNavigator.canGoForward
111+
snapshotFlow { htmlNavigator.canGoBack to htmlNavigator.canGoForward }
112+
.collect { (canGoBack, canGoForward) ->
113+
navigator.canGoBack = canGoBack
114+
navigator.canGoForward = canGoForward
178115
}
179-
}
180116
}
181117

182118
LaunchedEffect(state.content) {
183-
stateAdapter.syncFromCommon()
184-
185119
when (state.content) {
186120
is WebContent.Url -> {
187121
htmlViewState.content = HtmlContent.Url(
@@ -222,19 +156,35 @@ actual fun ActualWebView(
222156
}
223157
}
224158

225-
LaunchedEffect(htmlViewState.lastLoadedUrl, htmlViewState.pageTitle, htmlViewState.loadingState) {
226-
val wasmState = stateAdapter.getWasmWebViewState()
227-
228-
htmlViewState.lastLoadedUrl?.let { wasmState.lastLoadedUrl = it }
229-
htmlViewState.pageTitle?.let { wasmState.pageTitle = it }
230-
231-
wasmState.isLoading = when (htmlViewState.loadingState) {
232-
is HtmlLoadingState.Loading -> true
233-
is HtmlLoadingState.Finished -> false
234-
is HtmlLoadingState.Initializing -> false
159+
// Sync HtmlViewState → WebViewState using snapshotFlow to avoid missing intermediate states
160+
LaunchedEffect(htmlViewState, state) {
161+
snapshotFlow {
162+
Triple(htmlViewState.lastLoadedUrl, htmlViewState.pageTitle, htmlViewState.loadingState)
163+
}.collect { (lastLoadedUrl, pageTitle, loadingState) ->
164+
lastLoadedUrl?.let { state.lastLoadedUrl = it }
165+
pageTitle?.let { state.pageTitle = it }
166+
167+
when (loadingState) {
168+
is HtmlLoadingState.Loading -> {
169+
// Simulate progress like desktop: iframe doesn't provide real progress,
170+
// so we animate from 0.1 to 0.9 while loading
171+
state.loadingState = LoadingState.Loading(0.1f)
172+
scope.launch {
173+
while (htmlViewState.loadingState is HtmlLoadingState.Loading) {
174+
kotlinx.coroutines.delay(100)
175+
val current = state.loadingState
176+
if (current is LoadingState.Loading) {
177+
state.loadingState = LoadingState.Loading(
178+
(current.progress + 0.02f).coerceAtMost(0.9f)
179+
)
180+
}
181+
}
182+
}
183+
}
184+
is HtmlLoadingState.Finished -> state.loadingState = LoadingState.Finished
185+
is HtmlLoadingState.Initializing -> state.loadingState = LoadingState.Initializing
186+
}
235187
}
236-
237-
stateAdapter.syncToCommon()
238188
}
239189

240190
HtmlView(
@@ -268,13 +218,14 @@ actual fun ActualWebView(
268218
element = element,
269219
nativeWebView = nativeWebView,
270220
scope = scope,
271-
webViewJsBridge = webViewJsBridge
221+
webViewJsBridge = webViewJsBridge,
222+
onLoadStarted = { htmlViewState.loadingState = HtmlLoadingState.Loading },
272223
)
273224

274225
state.webView = webViewWrapper
275226

276227
if (webViewJsBridge != null) {
277-
setupJsBridgeForWasm(element, webViewJsBridge, webViewWrapper)
228+
bridgeCleanup.value = setupJsBridgeForWasm(element, webViewJsBridge, webViewWrapper)
278229
}
279230

280231
if (state.content is WebContent.File) {
@@ -288,6 +239,8 @@ actual fun ActualWebView(
288239
onCreated(nativeWebView)
289240
},
290241
onDispose = { element ->
242+
bridgeCleanup.value?.invoke()
243+
bridgeCleanup.value = null
291244
state.webView?.let {
292245
onDispose(NativeWebView(element))
293246
state.webView = null
@@ -297,13 +250,14 @@ actual fun ActualWebView(
297250
}
298251

299252
/**
300-
* Set up the JavaScript bridge for WasmJS platform
253+
* Set up the JavaScript bridge for WasmJS platform.
254+
* Returns a cleanup function that removes the message listener.
301255
*/
302256
private fun setupJsBridgeForWasm(
303257
element: HTMLIFrameElement,
304258
webViewJsBridge: WebViewJsBridge,
305259
webViewWrapper: WasmJsWebView
306-
) {
260+
): () -> Unit {
307261
val messageHandler: (org.w3c.dom.events.Event) -> Unit = { event ->
308262
val messageEvent = event as org.w3c.dom.MessageEvent
309263

@@ -348,4 +302,6 @@ private fun setupJsBridgeForWasm(
348302

349303
kotlinx.browser.window.addEventListener("message", messageHandler)
350304
webViewJsBridge.webView = webViewWrapper
305+
306+
return { kotlinx.browser.window.removeEventListener("message", messageHandler) }
351307
}

webview-compose/src/wasmJsMain/kotlin/io/github/kdroidfilter/webview/web/WebViewTypes.kt

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,6 @@ import androidx.compose.runtime.getValue
44
import androidx.compose.runtime.mutableStateOf
55
import androidx.compose.runtime.setValue
66

7-
/**
8-
* State class for WebView component
9-
* * Holds the current state of a WebView including URL, content, loading status, etc.
10-
*/
11-
class WasmJsWebViewState(
12-
initialUrl: String = "",
13-
initialContent: String = "",
14-
) {
15-
/** Current URL to be loaded */
16-
var url: String by mutableStateOf(initialUrl)
17-
18-
/** HTML content to be displayed */
19-
var content: String by mutableStateOf(initialContent)
20-
21-
/** Last URL that was successfully loaded */
22-
var lastLoadedUrl: String? by mutableStateOf(null)
23-
24-
/** Whether the WebView is currently loading content */
25-
var isLoading: Boolean by mutableStateOf(false)
26-
27-
/** Title of the current page */
28-
var pageTitle: String? by mutableStateOf(null)
29-
}
30-
317
/**
328
* Content types for HTML view
339
*/

0 commit comments

Comments
 (0)