Menu Event Handlers
Correctly handle menu events in Tauri v2 without blocking the main thread.
The critical rule
Warning
on_menu_event runs on the main thread. Never perform blocking operations (I/O, network, process management) directly inside the handler. Blocking the main thread freezes the entire application UI.
Menu creation
Tauri v2 provides two styles for building menus. Both work; choose based on complexity.
Builder style (simpler menus)
Use SubmenuBuilder for straightforward menus:
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
let app_menu = SubmenuBuilder::new(app, "MyApp")
.about(None)
.separator()
.services()
.separator()
.hide()
.hide_others()
.show_all()
.separator()
.quit()
.build()?;
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.select_all()
.build()?;
let view_menu = SubmenuBuilder::new(app, "View")
.item(
&MenuItemBuilder::with_id("refresh", "Refresh")
.accelerator("CmdOrCtrl+R")
.build(app)?,
)
.item(
&MenuItemBuilder::with_id("devtools", "Toggle Developer Tools")
.accelerator("CmdOrCtrl+Alt+I")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::with_id("zoom_in", "Zoom In")
.accelerator("CmdOrCtrl+=")
.build(app)?,
)
.item(
&MenuItemBuilder::with_id("zoom_out", "Zoom Out")
.accelerator("CmdOrCtrl+-")
.build(app)?,
)
.build()?;
let menu = MenuBuilder::new(app)
.item(&app_menu)
.item(&edit_menu)
.item(&view_menu)
.build()?;
app.set_menu(menu)?;Warning
On macOS, app.set_menu() replaces the application menu; it does not merge omitted standard items back in. Keep Services, Hide (⌘H), Hide Others (⌥⌘H), Show All and Quit. These predefined services/show-all entries have no effect on unsupported platforms. For larger menus, build from one ordered menu specification and test its required entries so shortcuts are not silently removed. Verify shortcuts natively on macOS.
See Tauri 2.11.0 menu construction and app menu replacement.
Explicit style (full control)
Use Menu::new, Submenu::new, and PredefinedMenuItem for complete control:
use tauri::menu::{Menu, PredefinedMenuItem, Submenu};
let handle = app.handle();
let menu = Menu::new(handle)?;
// App menu (macOS only)
#[cfg(target_os = "macos")]
{
let app_menu = Submenu::new(handle, "zudotext", true)?;
app_menu.append(
&PredefinedMenuItem::about(handle, Some("About zudotext"), None)?,
)?;
app_menu.append(&PredefinedMenuItem::separator(handle)?)?;
app_menu.append(&PredefinedMenuItem::services(handle, None)?)?;
app_menu.append(&PredefinedMenuItem::separator(handle)?)?;
app_menu.append(&PredefinedMenuItem::hide(handle, None)?)?;
app_menu.append(&PredefinedMenuItem::hide_others(handle, None)?)?;
app_menu.append(&PredefinedMenuItem::show_all(handle, None)?)?;
app_menu.append(&PredefinedMenuItem::separator(handle)?)?;
app_menu.append(&PredefinedMenuItem::quit(handle, None)?)?;
menu.append(&app_menu)?;
}
// File menu with custom items
let file_menu = Submenu::new(handle, "File", true)?;
file_menu.append(
&tauri::menu::MenuItemBuilder::with_id("new_window", "New Window")
.accelerator("CmdOrCtrl+N")
.build(handle)?,
)?;
file_menu.append(&PredefinedMenuItem::separator(handle)?)?;
file_menu.append(
&PredefinedMenuItem::close_window(handle, None)?,
)?;
menu.append(&file_menu)?;
app.set_menu(menu)?;Note
Predefined menu items (Copy, Paste, Undo, etc.) are handled automatically by the OS and do not appear in on_menu_event. Only custom items with MenuItemBuilder::with_id() trigger the handler.
Event handling patterns
Non-blocking: webview eval (safe on main thread)
Simple webview evaluations are fast and safe to run directly in the handler:
app.on_menu_event(|app_handle, event| {
let id = event.id().0.as_str();
match id {
"reload" => {
if let Some(w) = app_handle.get_webview_window("main") {
let _ = w.eval("window.location.reload()");
}
}
"toggle_devtools" => {
if let Some(w) = app_handle.get_webview_window("main") {
if w.is_devtools_open() {
w.close_devtools();
} else {
w.open_devtools();
}
}
}
"zoom_in" => {
if let Some(w) = app_handle.get_webview_window("main") {
let _ = w.eval(
"{ const z = Math.min(3, \
parseFloat(document.body.style.zoom || '1') * 1.1); \
document.body.style.zoom = z.toString(); }",
);
}
}
_ => {}
}
});Blocking: spawn a background thread
For operations that involve I/O (sidecar management, file operations, network requests), spawn a thread:
app.on_menu_event(|app_handle, event| {
match event.id().as_ref() {
"refresh" => {
// Clone handle for the background thread
let handle = app_handle.clone();
std::thread::spawn(move || {
do_refresh(&handle); // This does I/O, waits for server
});
}
_ => {}
}
});Warning
The do_refresh function in this example kills a sidecar process, waits for a port to become available, spawns a new process, and polls for readiness. Any of these operations could block for seconds. Running them on the main thread would freeze the app.
Timeout polling pattern
When polling for a dev server or sidecar readiness, use a bool flag checked against Instant::elapsed():
fn wait_for_ready(timeout: Duration) {
let start = Instant::now();
while start.elapsed() < timeout {
let code = check_ready();
if code != "000" && code != "err" {
// Server is ready
return;
}
thread::sleep(Duration::from_secs(1));
}
// Timeout reached
eprintln!("Server did not respond within timeout");
}Tip
Use Instant::now() and elapsed() for timeout checks instead of re-computing elapsed time from a stored SystemTime. Instant is monotonic and immune to system clock adjustments.
Creating windows from menu handlers
Creating a new window from a menu handler requires the AppHandle, not the App reference:
"new_window" => {
if let Err(e) = window::create_main_window(&handle) {
eprintln!("Failed to create new window: {e}");
}
}Window creation itself is not blocking I/O, so it can be done directly in the handler. However, if the window creation triggers a dev server poll (as in the development workflow), that polling must be spawned on a background thread inside create_main_window.
Registering the handler
Keep menu creation and event handling in a dedicated module:
// native/menu.rs
pub fn create_menu(app: &tauri::App) -> tauri::Result<Menu<tauri::Wry>> {
// ... build menu
}
pub fn register_menu_handler(app: &tauri::App) {
let handle = app.handle().clone();
app.on_menu_event(move |app_handle, event| {
// ... handle events
});
}Call both from setup():
.setup(|app| {
let menu = native::menu::create_menu(app)?;
app.set_menu(menu)?;
native::menu::register_menu_handler(app);
Ok(())
})Key takeaways
Never block
on_menu_event-- it runs on the main threadUse
thread::spawnfor I/O -- cloneAppHandleand move it into the threadWebview eval is safe --
w.eval()is non-blocking and fine on the main threadPredefined items do not trigger
on_menu_event-- only customMenuItemBuilder::with_iditems doUse
Instantfor timeouts -- monotonic clock, not affected by system time changesSeparate creation from handling -- keep
create_menuandregister_menu_handleras distinct functions
Related
macOS Window Tiling Shortcuts -- if you have a Window submenu, you must register it with
set_as_windows_menu_for_nsapp()for macOS tiling to work