From 14b8e187ea4ac22ae71015eb803ed52f37f607f3 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 08:14:39 +0300 Subject: [PATCH] fix(desktop): invalidate filtered catalog on changed hosted head --- crates/oa-desktop/src/app/hosted.rs | 13 ++++ crates/oa-desktop/src/app/hosted/tests.rs | 86 +++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/crates/oa-desktop/src/app/hosted.rs b/crates/oa-desktop/src/app/hosted.rs index 37352744..8b6fd16c 100644 --- a/crates/oa-desktop/src/app/hosted.rs +++ b/crates/oa-desktop/src/app/hosted.rs @@ -396,6 +396,19 @@ impl App { let catalog_changed = match &result { Ok(Outcome::Remote(remote)) => { matches!(request.operation, Operation::Publish | Operation::Save) + // A changed bound head can enter/leave the active + // filter or page even when no row was displayed. + || self.hosted.session.as_ref().is_some_and(|session| { + session.connection.base() == request.connection.base() + && session.resource_id == remote.resource_id + && session.etag != remote.etag + }) + || self.hosted.uncertain.as_ref().is_some_and(|failed| { + failed.operation == Operation::Save + && failed.connection.base() == request.connection.base() + && failed.resource_id == Some(remote.resource_id) + && failed.etag.as_ref().is_some_and(|etag| etag != &remote.etag) + }) || self.hosted.catalog.iter().any(|item| { item.resource_id == remote.resource_id && item.revision != remote.revision diff --git a/crates/oa-desktop/src/app/hosted/tests.rs b/crates/oa-desktop/src/app/hosted/tests.rs index 440e81b6..6865021c 100644 --- a/crates/oa-desktop/src/app/hosted/tests.rs +++ b/crates/oa-desktop/src/app/hosted/tests.rs @@ -729,3 +729,89 @@ fn hosted_catalog_invalidation_follows_service_writes_and_observed_revision_chan "Leaving the hosted destination cannot make old results fresh" ); } + +#[test] +fn hosted_new_revision_invalidates_filtered_catalog_even_without_a_displayed_row() { + let mut app = App::with_project(Project::empty()); + rename(&mut app, "Original"); + let resource = bind(&mut app); + send(&mut app, Event::Query("Renamed".into())); + send(&mut app, Event::Connect); + complete( + &mut app, + Ok(Outcome::Catalog(crate::hosted::client::Catalog { + items: vec![], + next_after: None, + })), + ); + assert!(!app.hosted.catalog_stale); + assert!( + app.hosted + .catalog_message() + .unwrap() + .starts_with("No models found") + ); + + let mut unchanged = remote(app.document.snapshot(), resource, 1); + unchanged.etag = app.hosted.session.as_ref().unwrap().etag.clone(); + send(&mut app, Event::Check); + complete(&mut app, Ok(Outcome::Remote(unchanged))); + assert!( + !app.hosted.catalog_stale, + "An unchanged bound head keeps the filtered snapshot fresh" + ); + + // A different engineer changes the hosted model so it now matches the filter. + let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap(); + changed + .execute(Command::Rename(changed.project().id, "Renamed".into())) + .unwrap(); + send(&mut app, Event::Check); + complete( + &mut app, + Ok(Outcome::Remote(remote(changed.snapshot(), resource, 2))), + ); + assert_eq!(app.hosted.review.as_ref().unwrap().remote.revision, 2); + assert!( + app.hosted.request.is_none(), + "No polling or automatic catalog request" + ); + assert!( + app.hosted.catalog_stale, + "Known newer revision can change membership in an empty filtered catalog" + ); + assert!( + !app.hosted + .catalog_message() + .unwrap() + .starts_with("No models found") + ); +} + +#[test] +fn hosted_changed_bound_head_does_not_invalidate_another_services_empty_filter() { + let mut app = App::with_project(Project::empty()); + let resource = bind(&mut app); + send(&mut app, Event::Base("http://127.0.0.1:9876".into())); + send(&mut app, Event::Query("Renamed".into())); + send(&mut app, Event::Connect); + complete( + &mut app, + Ok(Outcome::Catalog(crate::hosted::client::Catalog { + items: vec![], + next_after: None, + })), + ); + send(&mut app, Event::Check); + let changed = remote(app.document.snapshot(), resource, 2); + complete(&mut app, Ok(Outcome::Remote(changed))); + assert!(app.hosted.review.is_some()); + assert!(!app.hosted.catalog_stale); + assert!( + app.hosted + .catalog_message() + .unwrap() + .starts_with("No models found") + ); + assert!(app.hosted.request.is_none()); +}