hotwired / hotwired/hotwire-native-android

`onCreateWindow` silently drops `window.open` URLs (only recovers `target="_blank"` anchors)

Open
#212 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Kotlin
Stars
163
Forks
37
PR merge metrics
No merged PRs in 30d

Description

Summary

HotwireWebChromeClient.onCreateWindow recovers the new-window URL with
requestFocusNodeHref, which reads the href off the currently focused anchor node. A JS
window.open() call has no anchor node, so the URL comes back null,
session.visitProposedToLocation is never reached, and the tap silently does nothing — no
navigation, no browser, no error.

target="_blank" anchors work, so the feature looks functional until content opens links via JS.

Affects 1.3.1 and current main:

https://github.com/hotwired/hotwire-native-android/blob/main/core/src/main/kotlin/dev/hotwire/core/turbo/webview/HotwireWebChromeClient.kt

override fun onCreateWindow(
    webView: WebView,
    isDialog: Boolean,
    isUserGesture: Boolean,
    resultMsg: Message?
): Boolean {
    val message = webView.handler?.obtainMessage() ?: return false
    webView.requestFocusNodeHref(message)

    message.data.getString("url")?.let {
        session.visitProposedToLocation(
            location = it,
            optionsJson = VisitOptions().toJson()
        )
    }

    return false
}

Routing is not at fault — BrowserTabRouteDecisionHandler correctly sends off-host URLs to a
Custom Tab. It just never receives a URL.

Measurements

Pixel 9 emulator, API 36, WebView configured as HotwireWebView does (javaScriptEnabled,
domStorageEnabled, setSupportMultipleWindows(true)). Each link shape tapped with a real
MotionEvent (ACTION_DOWN/ACTION_UP), recording what the above mechanism recovers:

Link shape onCreateWindow fired URL recovered
target="_blank" anchor, top level yes yes
target="_blank" anchor, in iframe yes yes
window.open, top level yes no
window.open, in iframe yes no

onCreateWindow fires in all four cases — only URL recovery fails. The iframe is not
load-bearing; window.open is.

I also probed requestFocusNodeHref asynchronously (letting the handler deliver the message
rather than reading message.data synchronously). Both reads agreed, so the synchronous read is
not a race — the URL genuinely isn't available by this route.

Reproduction

Load this in a Hotwire Native Android app and tap it:

<div onclick="window.open('https://example.com/')"
     style="display:block;width:100vw;height:100vh">open</div>

Nothing happens. Swap the div for <a href="https://example.com/" target="_blank"> and it
opens a Custom Tab as expected.

Real-world case: Articulate Storyline content embedded in WordPress posts. Its outbound links
(e.g. Google Drive) are opened via window.open, so every one of them was inert.

Suggested fix

Recover the URL through the result message's WebView.WebViewTransport instead — hand the popup
a throwaway WebView and read the URL it is asked to load. That covers window.open and
target="_blank" alike, since it observes the actual navigation rather than inferring it from
DOM focus:

override fun onCreateWindow(
    webView: WebView,
    isDialog: Boolean,
    isUserGesture: Boolean,
    resultMsg: Message?
): Boolean {
    val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false
    val probe = WebView(webView.context)
    var handled = false

    fun consume(url: String?) {
        if (handled) return
        handled = true
        url?.let {
            session.visitProposedToLocation(
                location = it,
                optionsJson = VisitOptions().toJson()
            )
        }
        webView.post { probe.destroy() }
    }

    probe.webViewClient = object : WebViewClient() {
        override fun shouldOverrideUrlLoading(
            view: WebView?,
            request: WebResourceRequest?
        ): Boolean {
            consume(request?.url?.toString())
            return true
        }

        override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
            consume(url)
        }
    }

    transport.webView = probe
    resultMsg.sendToTarget()
    return true
}

I verified this recovers the URL for all four shapes in the table above, and confirmed
end-to-end on an emulator that a Storyline external link now opens a browser.

Two caveats on that sketch, if it's useful:

  • It returns true (the window request is consumed), where the current implementation returns
    false. Worth a look for anyone relying on the current return value.
  • A window.open that never navigates (e.g. window.open('')) would leak the throwaway
    WebView, since consume never runs. Rare, but a timeout or a Session-scoped probe would
    close it.

Authorship

This was investigated and written with Claude Opus 5 (Anthropic), so you should know what you're
reading. The findings are empirical rather than inferred from reading the source: the table above
comes from instrumented test runs on a Pixel 9 emulator (API 36) driving each link shape with real
MotionEvent taps, the suggested fix was verified against all four shapes the same way, and the
end-to-end behaviour was then confirmed by hand in a real app.

Note

The iOS counterpart had the same user-visible symptom by a different cause — WKUIController
didn't implement createWebViewWith at all, so window.open was dropped there too. Might be
worth checking the two implementations against the same set of link shapes.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in core/src/main/kotlin/dev/hotwire/core/turbo/webview/HotwireWebChromeClient.kt and reproduce the issue with the provided window.open and target="_blank" cases on an Android emulator. Trace how onCreateWindow obtains the URL and how session.visitProposedToLocation routes it. Done means both link shapes recover their URLs and external navigation still reaches the expected browser or Custom Tab without leaving probe resources behind.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, kotlin
Domain
mobile
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.