Fix: window listeners leaked past component unmount (dispose panic)
Leptos' window_event_listener returns a WindowListenerHandle with no Drop impl, so the component-body 'let _h = ...' bindings leaked: after a Browser remount (e.g. toggling the Admin view) the stale DialogView Escape handler kept firing on window and panicked reading the disposed dialog signal (reproduced in Chrome: Admin -> back -> open preview -> Escape). New util::owned_window_listener() registers the listener and ties handle.remove() to owner cleanup via on_cleanup. Replaced all 8 window_event_listener call sites (app hashchange, context menu, dialog, preview, editor x2, shares panel). Co-Authored-By: Qwen3.8 27b
Mserver/src/api/admin.rs
@@ -3,9 +3,9 @@
use std::sync::Arc;
use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use crate::api::common::AdminUser;
Mserver/src/api/auth.rs
@@ -1,10 +1,10 @@
use std::path::Path;
use std::sync::Arc;
use axum::Json;
use axum::extract::State;
use axum::http::{header, HeaderMap, StatusCode};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Deserialize;
use crate::auth::{self, clear_session_cookie, parse_session_cookie, session_cookie};
Mserver/src/api/common.rs
@@ -3,8 +3,8 @@
use std::sync::Arc;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::http::request::Parts;
use crate::auth::parse_session_cookie;
use crate::db::{RootRow, ShareRow, User};
Mserver/src/api/files.rs
@@ -14,10 +14,10 @@ use std::path::Component;
use std::sync::Arc;
use std::time::UNIX_EPOCH;
use axum::Json;
use axum::extract::{Path as AxumPath, Query as AxumQuery, State};
use axum::http::{header, StatusCode};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::Json;
use futures_util::StreamExt;
use multer::Multipart;
use serde::Deserialize;
@@ -783,7 +783,7 @@ fn validate_rel_path(name: &str) -> Result<(), ApiError> {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"invalid file path in upload",
))
));
}
}
}
Mserver/src/api/mod.rs
@@ -1,8 +1,8 @@
use std::sync::Arc;
use axum::Router;
use axum::http::HeaderValue;
use axum::routing::{delete, get, post, put};
use axum::Router;
use tower_http::set_header::SetResponseHeaderLayer;
use crate::error::AppState;
Mserver/src/api/spa.rs
@@ -1,5 +1,5 @@
use axum::http::Uri;
use axum::http::{header, Method, StatusCode};
use axum::http::{Method, StatusCode, header};
use axum::response::{IntoResponse, Response};
const DEV_HINT: &str = r#"<!doctype html>
Mserver/src/auth.rs
@@ -1,5 +1,5 @@
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
pub const COOKIE_NAME: &str = "fbng_session";
/// 30 days.
@@ -77,7 +77,7 @@ pub fn parse_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{header, HeaderMap};
use axum::http::{HeaderMap, header};
#[test]
fn password_hash_round_trip() {
Mserver/src/db.rs
@@ -1,7 +1,7 @@
use std::path::Path;
use std::sync::Arc;
use rusqlite::{params, Connection, OptionalExtension};
use rusqlite::{Connection, OptionalExtension, params};
const SCHEMA_VERSION: i64 = 2;
Mserver/src/lib.rs
@@ -7,7 +7,7 @@
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use anyhow::{bail, Context};
use anyhow::{Context, bail};
use clap::Parser;
use tower_http::trace::TraceLayer;
Mserver/tests/api_auth.rs
@@ -138,10 +138,11 @@ async fn logout_invalidates_session() {
let r = admin.post_json("/api/auth/logout", &json!({})).await;
assert_eq!(r.status, StatusCode::OK);
// The Set-Cookie header clears the cookie.
assert!(r
.header("set-cookie")
.unwrap()
.starts_with("fbng_session=;"));
assert!(
r.header("set-cookie")
.unwrap()
.starts_with("fbng_session=;")
);
// The old cookie no longer authenticates.
assert_eq!(
admin.get("/api/auth/me").await.status,
Mserver/tests/api_files.rs
@@ -227,10 +227,11 @@ async fn preview_serves_inline_and_rejects_dirs() {
.get(&format!("{}?action=preview", root_path("config.json")))
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(r
.header("content-disposition")
.unwrap()
.starts_with("inline;"));
assert!(
r.header("content-disposition")
.unwrap()
.starts_with("inline;")
);
assert_eq!(r.body, b"{\"k\": 1}");
assert_eq!(
admin
Mserver/tests/api_spa.rs
@@ -43,7 +43,10 @@ async fn spa_fallback_and_api_guards() {
let csp = r.header("content-security-policy").unwrap();
assert!(csp.starts_with("default-src 'self'"), "CSP: {csp}");
assert!(csp.contains("frame-ancestors 'none'"), "CSP: {csp}");
assert_eq!(r.header("x-content-type-options").as_deref(), Some("nosniff"));
assert_eq!(
r.header("x-content-type-options").as_deref(),
Some("nosniff")
);
assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY"));
assert_eq!(r.header("referrer-policy").as_deref(), Some("no-referrer"));
Mserver/tests/common/mod.rs
@@ -8,9 +8,9 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{header, HeaderMap, Method, StatusCode};
use axum::Router;
use axum::body::Body;
use axum::http::{HeaderMap, Method, StatusCode, header};
use http_body_util::BodyExt;
use server::db::Db;
use server::error::AppState;
@@ -63,11 +63,7 @@ impl Env {
https: false,
});
let app = server::api::router(state.clone());
Self {
root,
state,
app,
}
Self { root, state, app }
}
/// Absolute path of a fixture file inside the root.
Mweb/src/app.rs
@@ -2,7 +2,7 @@ use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me};
use crate::router::{parse_location, Location};
use crate::router::{Location, parse_location};
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum AuthPhase {
@@ -21,7 +21,7 @@ pub fn App() -> impl IntoView {
// The URL hash is the source of truth for the location.
{
set_loc.set(parse_location());
let _h = window_event_listener(leptos::ev::hashchange, move |_| {
crate::util::owned_window_listener(leptos::ev::hashchange, move |_| {
set_loc.set(parse_location());
});
}
Mweb/src/components/icon.rs
@@ -54,7 +54,9 @@ fn icon_d(name: IconName) -> &'static str {
"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
}
IconName::File => "M6 2h8l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z M13 2v6h6",
IconName::Image => "M3 5h18v14H3z M10 10a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0z M21 15l-5-5-9 9",
IconName::Image => {
"M3 5h18v14H3z M10 10a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0z M21 15l-5-5-9 9"
}
IconName::Video => "M3 5h18v14H3z M10 9l5 3-5 3z",
IconName::Audio => {
"M9 18V6l10-2v10 M9 18a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z M19 15a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"
@@ -62,7 +64,9 @@ fn icon_d(name: IconName) -> &'static str {
IconName::Text => "M5 3h14v18H5z M9 8h6 M9 12h6 M9 16h4",
IconName::Code => "M8 8l-4 4 4 4 M16 8l4 4-4 4",
IconName::Archive => "M4 4h16v16H4z M12 4v2 M12 8v2 M12 12v2 M9 16h6v4H9z",
IconName::Pdf => "M6 2h8l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z M13 2v6h6 M8 13h8 M8 17h8",
IconName::Pdf => {
"M6 2h8l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z M13 2v6h6 M8 13h8 M8 17h8"
}
IconName::Grid => "M4 4h7v7H4z M13 4h7v7h-7z M4 13h7v7H4z M13 13h7v7h-7z",
IconName::List => "M4 6h2 M9 6h11 M4 12h2 M9 12h11 M4 18h2 M9 18h11",
IconName::Refresh => "M21 12a9 9 0 1 1-3-6.7 M21 3v6h-6",
Mweb/src/editor.rs
@@ -6,14 +6,14 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use leptos::prelude::*;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
use crate::api::{self, ApiError};
use crate::cm;
use crate::components::toast::{show, ToastMsg};
use crate::components::toast::{ToastMsg, show};
/// A text file the user wants to edit.
#[derive(Clone)]
@@ -234,12 +234,15 @@ pub fn EditorModal(
// Ctrl/Cmd+S saves (scoped to the editor's lifetime).
{
let cb = save_cb;
let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if (ev.ctrl_key() || ev.meta_key()) && (ev.key() == "s" || ev.key() == "S") {
ev.prevent_default();
cb.run(());
}
});
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if (ev.ctrl_key() || ev.meta_key()) && (ev.key() == "s" || ev.key() == "S") {
ev.prevent_default();
cb.run(());
}
},
);
}
// Escape: closes the unsaved-changes prompt if open, otherwise closes the
@@ -250,16 +253,19 @@ pub fn EditorModal(
let scd = set_confirm_discard;
let cf = conflict;
let rc = request_close;
let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if ev.key() != "Escape" {
return;
}
if cd.get() {
scd.set(false);
} else if !cf.get() {
rc.run(());
}
});
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() != "Escape" {
return;
}
if cd.get() {
scd.set(false);
} else if !cf.get() {
rc.run(());
}
},
);
}
view! {
Mweb/src/preview.rs
@@ -81,11 +81,14 @@ pub fn PreviewModal(
// Escape closes the preview.
{
let c = close;
let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
c.run(());
}
});
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
c.run(());
}
},
);
}
view! {
<div class="modal-overlay preview-overlay" on:click=move |_| close.run(())>
Mweb/src/util.rs
@@ -1,6 +1,23 @@
//! Formatting + small browser helpers.
use chrono::{DateTime, Utc};
use leptos::prelude::*;
/// A window event listener that is removed when the current owner is disposed.
///
/// Leptos' `window_event_listener` returns a `WindowListenerHandle` with no
/// `Drop` impl — if the owning component is unmounted without an explicit
/// `remove()`, the listener keeps firing on `window` and panics by reading
/// the component's now-disposed signals.
pub fn owned_window_listener<E: leptos::ev::EventDescriptor + 'static>(
event: E,
cb: impl Fn(E::EventType) + 'static,
) where
E::EventType: wasm_bindgen::JsCast,
{
let handle = window_event_listener(event, cb);
on_cleanup(move || handle.remove());
}
pub fn format_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
Mweb/src/views/admin.rs
@@ -6,7 +6,7 @@ use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AdminUser, Me};
use crate::components::toast::{show, ToastMsg};
use crate::components::toast::{ToastMsg, show};
// ---------------------------------------------------------------------------
// Top-level admin view with tabs
Mweb/src/views/browser.rs
@@ -6,12 +6,12 @@ use web_sys::MouseEvent;
use crate::api::{self, Entry, Me, RootInfo};
use crate::cm;
use crate::components::icon::{icon_for, Icon, IconName};
use crate::components::toast::{show, ToastMsg};
use crate::components::icon::{Icon, IconName, icon_for};
use crate::components::toast::{ToastMsg, show};
use crate::editor::{EditTarget, EditorModal};
use crate::preview::{preview_kind, PreviewKind, PreviewModal, PreviewTarget};
use crate::router::{navigate, Location};
use crate::util::{format_date, format_size, ViewMode};
use crate::preview::{PreviewKind, PreviewModal, PreviewTarget, preview_kind};
use crate::router::{Location, navigate};
use crate::util::{ViewMode, format_date, format_size};
use crate::views::dialogs::{Dialog, DialogView, Op};
#[derive(Clone, Debug, PartialEq)]
@@ -572,14 +572,17 @@ fn CtxMenuView(
) -> impl IntoView {
// Close on any click or Escape.
{
let _h1 = window_event_listener(leptos::ev::click, move |_| {
crate::util::owned_window_listener(leptos::ev::click, move |_| {
set_ctx.set(None);
});
let _h2 = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
set_ctx.set(None);
}
});
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
set_ctx.set(None);
}
},
);
}
view! {
@@ -814,11 +817,7 @@ fn CtxMenuView(
/// `Some(cb)` when the current folder is writable, else `None` (disabled item).
fn rw_action(is_rw: bool, cb: Callback<()>) -> Option<Callback<()>> {
if is_rw {
Some(cb)
} else {
None
}
if is_rw { Some(cb) } else { None }
}
#[component]
Mweb/src/views/dialogs.rs
@@ -7,7 +7,7 @@ use web_sys::MouseEvent;
use crate::api::{self, Entry, RootInfo};
use crate::components::icon::{Icon, IconName};
use crate::components::toast::{show, ToastMsg};
use crate::components::toast::{ToastMsg, show};
/// Which mutating operation the destination picker is for.
#[derive(Clone, Copy, PartialEq)]
@@ -97,11 +97,14 @@ pub fn DialogView(
{
let d = dialog;
let set_d = set_dialog;
let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" && d.get().is_some() {
set_d.set(None);
}
});
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" && d.get().is_some() {
set_d.set(None);
}
},
);
}
view! {
{move || {
Mweb/src/views/login.rs
@@ -1,9 +1,9 @@
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, input_value, Me};
use crate::api::{self, Me, input_value};
use crate::app::AuthPhase;
use crate::router::{navigate, Location};
use crate::router::{Location, navigate};
#[component]
pub fn LoginView(
Mweb/src/views/setup.rs
@@ -1,9 +1,9 @@
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, input_value, Me};
use crate::api::{self, Me, input_value};
use crate::app::AuthPhase;
use crate::router::{navigate, Location};
use crate::router::{Location, navigate};
#[component]
pub fn SetupView(
Mweb/src/views/shell.rs
@@ -2,7 +2,7 @@ use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me};
use crate::components::toast::{provide_toast, ToastView};
use crate::components::toast::{ToastView, provide_toast};
use crate::router::Location;
use crate::views::admin::AdminView;
use crate::views::browser::Browser;