hotwired / hotwired/hotwire-native-android
`onCreateWindow` silently drops `window.open` URLs (only recovers `target="_blank"` anchors)
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:
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.openthat never navigates (e.g.window.open('')) would leak the throwaway
WebView, sinceconsumenever runs. Rare, but a timeout or aSession-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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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