The Zombie Input Session
A Nasty Mobile WebKit Bug You've Probably Never Heard Of
If you're building a web app with a rich text editor inside a modal/dialog, and you're targeting mobile Safari, this one's for you.
I recently spent an embarrassing amount of time tracking down a renderer crash on iOS that presented as a ~10 second delayed hang, followed by a white flash and the browser's "This webpage was reloaded because a problem occurred" message. No JavaScript errors. No network failures. Just a hard renderer crash with almost nothing to go on.
The setup
The app has a contenteditable-based rich text editor (Milkdown/ProseMirror) mounted inside a Kobalte dialog component (yes, for me, I was building this with Solid.js). When the dialog opens, we auto-focus the editor — necessary on mobile because programmatic focus has to be triggered explicitly in response to a user gesture on iOS. This works fine.
The bug
When the user closed the dialog, we weren't explicitly blurring the editor before teardown. On desktop this is a non-issue — the browser handles it gracefully. On iOS it's a different story.
iOS maintains an internal input/keyboard session tied to the focused contenteditable element. When the dialog closed without a proper .blur(), that session was left in a zombie state. iOS held it open for roughly 8-10 seconds, apparently attempting its own internal reconciliation. Ironically, if the user reopened the editor within that window, things still worked — the session was still alive enough to recover. It was opening the editor after that threshold, once iOS had finished its own cleanup and fully killed the session, that caused the crash. The next interaction hit a session that was now completely dead, and iOS does not handle that gracefully at all. Hard renderer crash.
The timing correlation is what made this so hard to identify. The same actions performed without any pause worked perfectly. Wait past that ~10 second threshold and everything blew up. No JS error, no warning, nothing catchable — because the failure was happening beneath the JavaScript layer entirely, in iOS's own input session management.
The fix
Explicitly calling .blur() on the editor element when the dialog closes, before teardown. We handle this inside a focus bridge module that was already responsible for managing auto-focus on mobile, so it was a natural place to own the full input session lifecycle — acquiring it on open, releasing it on close.
The fix is straightforward in concept, but the implementation has an important detail: a single .blur() call isn't always enough. iOS needs a moment to actually process the session release, so you have to be somewhat aggressive about it — blurring immediately, then again on the next animation frame, then once more after a short delay. Belt-and-suspenders, across multiple async boundaries.
function clearActiveTextInputSession(reason, options = {}) {
const followupDelayMs = options.followupDelayMs ?? 120
let cancelled = false
let animationFrameId = null
let timeoutId = null
const blurTargets = () => {
if (cancelled) return
const activeElement = document.activeElement
if (activeElement?.blur) {
activeElement.blur()
}
if (options.focusProxyRef && options.focusProxyRef !== activeElement) {
options.focusProxyRef.blur()
}
}
// Blur immediately, then again on the next animation frame, then once more
// after a short delay. Belt-and-suspenders approach to ensure iOS fully
// releases the input session before the dialog tears down.
blurTargets()
animationFrameId = requestAnimationFrame(() => blurTargets())
timeoutId = setTimeout(() => blurTargets(), followupDelayMs)
return {
cancel: () => {
cancelled = true
if (animationFrameId !== null) cancelAnimationFrame(animationFrameId)
if (timeoutId) clearTimeout(timeoutId)
},
}
}
Then on dialog close:
// Call this before or during dialog close, before the editor unmounts
clearActiveTextInputSession("editor-closed")
The key insight is that on iOS, you need to think of contenteditable focus as a session with an explicit open and close, not just a DOM property. The OS is managing keyboard and input state underneath your web layer, and if you don't release that state cleanly — and give it enough time across async boundaries to actually process that release — it will clean it up for you at the worst possible moment.
What I'd expect
Honestly? The platform should handle this. When a contenteditable element is hidden or its containing tree is unmounted, iOS should clean up its own input session automatically. The fact that it doesn't — and that the failure mode is a hard renderer crash rather than anything catchable — is a genuine platform deficiency. But until Apple fixes it, explicit blur on close is the workaround.
If you're building anything with a rich text editor inside a modal on mobile Safari, save yourself the debugging session and add the cleanup now.