ignore late page/title events from suspended tabs (white screen on tab restore)

when a loading tab was suspended its webview was destroyed while events
were still queued, and a finished load from the dying webview could
overwrite the tab's stored URL with an intermediate redirect URL. on
switch-back the tab then reloaded the wrong page and stayed blank.

each webview instance now gets a generation (epoch): events carry it and
are dropped when they don't match, and suspending a tab invalidates its
epoch. verified tab cycling still restores pages normally.
This commit is contained in:
Martin Dvořák 2026-10-11 19:35:51 +02:00
commit 0c6b62ae3b

View file

@ -39,10 +39,12 @@ enum UserEvent {
ChromeIpc(String), ChromeIpc(String),
TitleChanged { TitleChanged {
tab_id: u32, tab_id: u32,
epoch: u32,
title: String, title: String,
}, },
PageLoaded { PageLoaded {
tab_id: u32, tab_id: u32,
epoch: u32,
url: String, url: String,
}, },
DownloadStarted { DownloadStarted {
@ -73,6 +75,10 @@ struct Tab {
url: String, url: String,
kir_enabled: bool, kir_enabled: bool,
webview: Option<WebView>, webview: Option<WebView>,
// Identifies the current webview instance for this tab. Events (page load,
// title) carry the epoch they were emitted from so that late events from a
// destroyed webview (suspended tab) cannot corrupt the tab state.
epoch: u32,
back_stack: Vec<String>, back_stack: Vec<String>,
forward_stack: Vec<String>, forward_stack: Vec<String>,
pending_nav_from_stack: bool, pending_nav_from_stack: bool,
@ -94,6 +100,7 @@ struct BrowserState {
downloads_panel_open: bool, downloads_panel_open: bool,
active: usize, active: usize,
next_id: u32, next_id: u32,
next_epoch: u32,
private: bool, private: bool,
user_agent: Option<String>, user_agent: Option<String>,
web_context: Option<WebContext>, web_context: Option<WebContext>,
@ -133,6 +140,13 @@ impl BrowserState {
self.next_id += 1; self.next_id += 1;
id id
} }
fn alloc_epoch(&mut self) -> u32 {
self.next_epoch = self.next_epoch.wrapping_add(1);
if self.next_epoch == 0 {
self.next_epoch = 1;
}
self.next_epoch
}
fn find_index(&self, tab_id: u32) -> Option<usize> { fn find_index(&self, tab_id: u32) -> Option<usize> {
self.tabs.iter().position(|t| t.id == tab_id) self.tabs.iter().position(|t| t.id == tab_id)
} }
@ -1182,6 +1196,7 @@ pub fn launch_webkit(args: &Cli) -> Result<()> {
downloads_panel_open: false, downloads_panel_open: false,
active: 0, active: 0,
next_id: 1, next_id: 1,
next_epoch: 1,
private: args.private, private: args.private,
user_agent: if args.ua_chrome { user_agent: if args.ua_chrome {
Some(chrome_user_agent()) Some(chrome_user_agent())
@ -1385,16 +1400,23 @@ fn handle_event(
) -> Result<()> { ) -> Result<()> {
match ev { match ev {
UserEvent::ChromeIpc(msg) => handle_chrome_ipc(&msg, window, proxy, state)?, UserEvent::ChromeIpc(msg) => handle_chrome_ipc(&msg, window, proxy, state)?,
UserEvent::TitleChanged { tab_id, title } => { UserEvent::TitleChanged {
tab_id,
epoch,
title,
} => {
if let Some(i) = state.find_index(tab_id) { if let Some(i) = state.find_index(tab_id) {
if !title.trim().is_empty() { if state.tabs[i].epoch == epoch && !title.trim().is_empty() {
state.tabs[i].title = title; state.tabs[i].title = title;
push_tabs(state); push_tabs(state);
} }
} }
} }
UserEvent::PageLoaded { tab_id, url } => { UserEvent::PageLoaded { tab_id, epoch, url } => {
if let Some(i) = state.find_index(tab_id) { if let Some(i) = state.find_index(tab_id) {
if state.tabs[i].epoch != epoch {
return Ok(());
}
if !url.is_empty() && url != "about:blank" { if !url.is_empty() && url != "about:blank" {
let previous = state.tabs[i].url.clone(); let previous = state.tabs[i].url.clone();
if url != previous { if url != previous {
@ -1701,6 +1723,7 @@ fn handle_chrome_ipc(
state.tabs[i].kir_enabled = !state.tabs[i].kir_enabled; state.tabs[i].kir_enabled = !state.tabs[i].kir_enabled;
if i != state.active && !state.tabs[i].kir_enabled { if i != state.active && !state.tabs[i].kir_enabled {
state.tabs[i].webview = None; state.tabs[i].webview = None;
state.tabs[i].epoch = 0;
} }
apply_visibility(state); apply_visibility(state);
push_tabs(state); push_tabs(state);
@ -1883,6 +1906,7 @@ fn build_tab_webview(
user_agent: Option<&str>, user_agent: Option<&str>,
mut web_context: Option<&mut WebContext>, mut web_context: Option<&mut WebContext>,
tab_id: u32, tab_id: u32,
epoch: u32,
url: &str, url: &str,
bounds: Rect, bounds: Rect,
) -> Result<(WebView, WebViewHostMode)> { ) -> Result<(WebView, WebViewHostMode)> {
@ -1902,6 +1926,7 @@ fn build_tab_webview(
user_agent, user_agent,
&init_script, &init_script,
tab_id, tab_id,
epoch,
url, url,
bounds, bounds,
) )
@ -1919,6 +1944,7 @@ fn build_tab_webview(
user_agent, user_agent,
&init_script, &init_script,
tab_id, tab_id,
epoch,
url, url,
bounds, bounds,
) )
@ -1935,6 +1961,7 @@ fn build_tab_webview(
user_agent, user_agent,
&init_script, &init_script,
tab_id, tab_id,
epoch,
url, url,
bounds, bounds,
) )
@ -1957,6 +1984,7 @@ fn build_tab_webview(
user_agent, user_agent,
&init_script, &init_script,
tab_id, tab_id,
epoch,
url, url,
bounds, bounds,
) )
@ -1982,6 +2010,7 @@ fn build_tab_webview(
user_agent, user_agent,
&init_script, &init_script,
tab_id, tab_id,
epoch,
url, url,
bounds, bounds,
) )
@ -2009,6 +2038,7 @@ fn tab_webview_builder<'a>(
user_agent: Option<&str>, user_agent: Option<&str>,
init_script: &str, init_script: &str,
tab_id: u32, tab_id: u32,
epoch: u32,
url: &str, url: &str,
bounds: Rect, bounds: Rect,
) -> WebViewBuilder<'a> { ) -> WebViewBuilder<'a> {
@ -2036,11 +2066,15 @@ fn tab_webview_builder<'a>(
false false
}) })
.with_document_title_changed_handler(move |title| { .with_document_title_changed_handler(move |title| {
let _ = title_proxy.send_event(UserEvent::TitleChanged { tab_id, title }); let _ = title_proxy.send_event(UserEvent::TitleChanged {
tab_id,
epoch,
title,
});
}) })
.with_on_page_load_handler(move |event, url| { .with_on_page_load_handler(move |event, url| {
if matches!(event, PageLoadEvent::Finished) { if matches!(event, PageLoadEvent::Finished) {
let _ = page_proxy.send_event(UserEvent::PageLoaded { tab_id, url }); let _ = page_proxy.send_event(UserEvent::PageLoaded { tab_id, epoch, url });
} }
}) })
.with_download_started_handler(move |url, destination| { .with_download_started_handler(move |url, destination| {
@ -2126,6 +2160,7 @@ fn open_tab(
url: String, url: String,
) -> Result<()> { ) -> Result<()> {
let tab_id = state.alloc_id(); let tab_id = state.alloc_id();
let epoch = state.alloc_epoch();
let chrome_h = chrome_height(state); let chrome_h = chrome_height(state);
let tab_bounds = content_bounds(window, chrome_h, state.host_mode); let tab_bounds = content_bounds(window, chrome_h, state.host_mode);
let (webview, effective_host_mode) = build_tab_webview( let (webview, effective_host_mode) = build_tab_webview(
@ -2138,6 +2173,7 @@ fn open_tab(
state.user_agent.as_deref(), state.user_agent.as_deref(),
state.web_context.as_mut(), state.web_context.as_mut(),
tab_id, tab_id,
epoch,
&url, &url,
tab_bounds, tab_bounds,
)?; )?;
@ -2149,6 +2185,7 @@ fn open_tab(
url, url,
kir_enabled: false, kir_enabled: false,
webview: Some(webview), webview: Some(webview),
epoch,
back_stack: Vec::new(), back_stack: Vec::new(),
forward_stack: Vec::new(), forward_stack: Vec::new(),
pending_nav_from_stack: false, pending_nav_from_stack: false,
@ -2227,6 +2264,7 @@ fn ensure_tab_loaded(
let chrome_h = chrome_height(state); let chrome_h = chrome_height(state);
let tab_bounds = content_bounds(window, chrome_h, state.host_mode); let tab_bounds = content_bounds(window, chrome_h, state.host_mode);
let tab_id = state.tabs[index].id; let tab_id = state.tabs[index].id;
let epoch = state.alloc_epoch();
let tab_url = state.tabs[index].url.clone(); let tab_url = state.tabs[index].url.clone();
let (webview, effective_host_mode) = build_tab_webview( let (webview, effective_host_mode) = build_tab_webview(
window, window,
@ -2238,18 +2276,23 @@ fn ensure_tab_loaded(
state.user_agent.as_deref(), state.user_agent.as_deref(),
state.web_context.as_mut(), state.web_context.as_mut(),
tab_id, tab_id,
epoch,
&tab_url, &tab_url,
tab_bounds, tab_bounds,
)?; )?;
state.host_mode = effective_host_mode; state.host_mode = effective_host_mode;
state.tabs[index].webview = Some(webview); state.tabs[index].webview = Some(webview);
state.tabs[index].epoch = epoch;
Ok(()) Ok(())
} }
fn suspend_background_tabs(state: &mut BrowserState) { fn suspend_background_tabs(state: &mut BrowserState) {
for (i, tab) in state.tabs.iter_mut().enumerate() { for (i, tab) in state.tabs.iter_mut().enumerate() {
if i != state.active && !tab.kir_enabled { if i != state.active && !tab.kir_enabled && tab.webview.is_some() {
tab.webview = None; tab.webview = None;
// Invalidate pending events from the destroyed webview so they
// cannot overwrite this tab's url/title while it is suspended.
tab.epoch = 0;
} }
} }
} }