Split off from #52, which is resolved — the freeze reported there is fixed in 0.4.5, the window now opens, paints and stays responsive on my setup. Thanks for that.
This is a separate problem that only appears once the window is closed. Repro script at the end.
Environment: @webviewjs/webview 0.4.5 · Arch Linux, kernel 7.2.3-arch1-3, X11/Xfce · gtk3 1:3.24.52-1 · webkit2gtk-4.1 2.52.6-1 · Node v26.8.1
Setup: typical tray app — Application → whenReady() → invisible anchor BrowserWindow (keeps the tray alive with no visible windows) → createTrayIcon() → visible BrowserWindow + createWebview() → app.run(), with win.on("close", () => win.hide()).
What happens
Pressing the window manager's close button destroys and disposes the window, with no notification:
- no
close event on the BrowserWindow (only blur);
- no
window-close-requested on the Application — and with the anchor window present, no application-close-requested either (without the anchor, that one does fire);
win.isDisposed() becomes true, so every method on the handle throws:
OK win.isDisposed() -> true
FAIL win.isVisible(): BrowserWindow has been disposed
FAIL win.show(): BrowserWindow has been disposed
So hide-to-tray is impossible: the close can't be vetoed, and there's no event to react to — the app only finds out when the next call throws out of a native callback and takes the process down. The same code works as expected on Windows.
Secondary findings
- GTK criticals on every close, suggesting the webview teardown runs after the
GdkWindow is gone:
Gdk-WARNING **: GdkWindow 0x9a00003 unexpectedly destroyed
GLib-GObject-CRITICAL **: invalid (NULL) pointer instance
GLib-GObject-CRITICAL **: g_signal_handler_disconnect: assertion 'G_TYPE_CHECK_INSTANCE (instance)' failed
Gdk-CRITICAL **: gdk_frame_clock_end_updating: assertion 'GDK_IS_FRAME_CLOCK (frame_clock)' failed
- Rebuilding the window with the same
WebContext renders blank. The protocol handler is called and returns the HTML, but page-load-finished never fires and nothing paints. A fresh WebContext on the same dataDirectory works, so it isn't the profile dir — the context goes bad when its window dies.
registerProtocol is per-context, not per-window: reusing a context and re-registering fails with Failed to create webview: Duplicate custom protocol 'app' registered on the WebViewBuilder.
app.exit() returns without throwing but the process is still alive a second later.
Workaround we're running
Guard every window call with isDisposed(); rebuild the whole window (BrowserWindow + Webview) on each "show"; drop the WebContext along with the window and create a new one on the same dataDirectory; re-register app:// on the new context behind isCustomProtocolRegistered(); keep a process.exit(0) fallback after app.exit().
Expected
Either the window emits close and stays alive so hide() works (as on Windows), or — if disposal is intentional on Linux — an event saying the window is gone, plus a WebContext the replacement window can reuse.
Repro
Single file, no deps beyond the library (uses xdotool to press the close button; close by hand otherwise). Walks through all the points above and prints what happens:
repro-close-quit.mjs
// Repro for @webviewjs/webview 0.4.5 on Linux / X11 / GTK3.
//
// Tray app pattern: an invisible anchor window keeps the app alive, and the
// visible window is meant to hide on close and come back from the tray.
//
// Run: node repro-close-quit.mjs (xdotool is used to press the WM close
// button; close it by hand if missing)
import { Application } from "@webviewjs/webview"
import { execFileSync } from "node:child_process"
import { mkdtempSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
const log = (...a) => console.log(`[${new Date().toISOString().slice(11, 23)}]`, ...a)
const step = (name, fn) => {
try {
const r = fn()
log(`OK ${name}`, r === undefined ? "" : `-> ${r}`)
return r
} catch (e) {
log(`FAIL ${name}: ${e?.message ?? e}`)
}
}
process.on("uncaughtException", (e) => log("uncaughtException:", e?.stack ?? e))
const PAGE = "<body style='background:#c33;font:700 40px sans-serif'>PAINTED</body>"
const PROFILE = mkdtempSync(join(tmpdir(), "webview-repro-"))
const app = new Application()
await app.whenReady()
// Invisible anchor window: keeps the tray alive with no visible windows.
app.createBrowserWindow({ visible: false })
// 16x16 PNG.
const icon = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGElEQVR4nGNgqLj+nyI8asCoAaMGDBcDAAwzTh8JA9THAAAAAElFTkSuQmCC",
"base64"
)
app.createTrayIcon({ icon: { data: icon }, tooltip: "repro", menu: { items: [{ id: "quit", label: "Quit" }] } })
let win = null
let webContext = null
let reuseContext = true // second pass flips this to show the difference
function openWindow() {
win = app.createBrowserWindow({ title: "repro-close-quit", width: 420, height: 300 })
// Intended "hide instead of destroy" behaviour. Never fires on Linux.
win.on("close", () => step("close event -> win.hide()", () => win.hide()))
if (!webContext || !reuseContext) {
webContext = app.createWebContext({ dataDirectory: PROFILE })
}
if (!webContext.isCustomProtocolRegistered("app")) {
win.registerProtocol("app", async () => {
log("protocol handler called")
return new Response(PAGE, { headers: { "Content-Type": "text/html" } })
})
}
const wv = win.createWebview({ url: "app://localhost/index.html", webContext, enableDevtools: false })
wv.on("page-load-finished", (e) => log("page-load-finished", e.url))
log("window created")
}
const wmClose = () => {
const ids = execFileSync("xdotool", ["search", "--name", "^repro-close-quit$"]).toString().trim().split("\n")
execFileSync("xdotool", ["windowclose", ids[ids.length - 1]])
}
openWindow()
setTimeout(() => {
log("--- 1. pressing the WM close button ---")
step("wm close", wmClose)
}, 3000)
setTimeout(() => {
log("--- 2. the JS handle after the WM close ---")
step("win.isDisposed()", () => win.isDisposed())
step("win.isVisible()", () => win.isVisible())
step("win.show()", () => win.show())
}, 5000)
setTimeout(() => {
log("--- 3. rebuilding the window, REUSING the same WebContext ---")
reuseContext = true
openWindow()
}, 6000)
setTimeout(() => {
log("(look at the window: it stays blank, and no page-load-finished above)")
step("wm close", wmClose)
}, 11000)
setTimeout(() => {
log("--- 4. rebuilding the window with a FRESH WebContext on the same dataDirectory ---")
reuseContext = false
openWindow()
}, 12000)
setTimeout(() => {
log("(this one paints)")
log("--- 5. quitting ---")
step("app.exit()", () => app.exit())
setTimeout(() => {
log("still running 1s after app.exit(); falling back to process.exit(0)")
process.exit(0)
}, 1000)
}, 17000)
app.run()
Happy to test any patch on real X11/GTK3 hardware.
Split off from #52, which is resolved — the freeze reported there is fixed in 0.4.5, the window now opens, paints and stays responsive on my setup. Thanks for that.
This is a separate problem that only appears once the window is closed. Repro script at the end.
Environment:
@webviewjs/webview0.4.5 · Arch Linux, kernel 7.2.3-arch1-3, X11/Xfce · gtk3 1:3.24.52-1 · webkit2gtk-4.1 2.52.6-1 · Node v26.8.1Setup: typical tray app —
Application→whenReady()→ invisible anchorBrowserWindow(keeps the tray alive with no visible windows) →createTrayIcon()→ visibleBrowserWindow+createWebview()→app.run(), withwin.on("close", () => win.hide()).What happens
Pressing the window manager's close button destroys and disposes the window, with no notification:
closeevent on theBrowserWindow(onlyblur);window-close-requestedon theApplication— and with the anchor window present, noapplication-close-requestedeither (without the anchor, that one does fire);win.isDisposed()becomestrue, so every method on the handle throws:So hide-to-tray is impossible: the close can't be vetoed, and there's no event to react to — the app only finds out when the next call throws out of a native callback and takes the process down. The same code works as expected on Windows.
Secondary findings
GdkWindowis gone:WebContextrenders blank. The protocol handler is called and returns the HTML, butpage-load-finishednever fires and nothing paints. A freshWebContexton the samedataDirectoryworks, so it isn't the profile dir — the context goes bad when its window dies.registerProtocolis per-context, not per-window: reusing a context and re-registering fails withFailed to create webview: Duplicate custom protocol 'app' registered on the WebViewBuilder.app.exit()returns without throwing but the process is still alive a second later.Workaround we're running
Guard every window call with
isDisposed(); rebuild the whole window (BrowserWindow+Webview) on each "show"; drop theWebContextalong with the window and create a new one on the samedataDirectory; re-registerapp://on the new context behindisCustomProtocolRegistered(); keep aprocess.exit(0)fallback afterapp.exit().Expected
Either the window emits
closeand stays alive sohide()works (as on Windows), or — if disposal is intentional on Linux — an event saying the window is gone, plus aWebContextthe replacement window can reuse.Repro
Single file, no deps beyond the library (uses
xdotoolto press the close button; close by hand otherwise). Walks through all the points above and prints what happens:repro-close-quit.mjsHappy to test any patch on real X11/GTK3 hardware.