From bc21943da3287f8aa37eaed99a36b3f6c479e688 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sun, 9 Aug 2026 23:26:37 +0200 Subject: [PATCH] fix(web): drop stale browse and browse-search responses --- web/src/crate_web/effects.gleam | 4 +- web/src/crate_web/msg.gleam | 8 +++- web/src/crate_web/update.gleam | 6 ++- web/src/crate_web/update/browse.gleam | 32 ++++++++++++---- web/test/browse_test.gleam | 53 ++++++++++++++++++++++++++- 5 files changed, 87 insertions(+), 16 deletions(-) diff --git a/web/src/crate_web/effects.gleam b/web/src/crate_web/effects.gleam index efe8dfa..f8c8e2f 100644 --- a/web/src/crate_web/effects.gleam +++ b/web/src/crate_web/effects.gleam @@ -552,14 +552,14 @@ pub fn adopt_scanned( /// The shared catalog grid: releases published by known crate users, /// folded against the viewer's own crate. pub fn load_browse() -> Effect(Msg) { - browse_request("", "", None, GotBrowse) + browse_request("", "", None, GotBrowse("", "", _)) } /// The substring/genre search over `catalog.listReleases`, shaped to match /// `load_browse` exactly (same decoder), so a search result drops straight /// into `model.browse`. pub fn search_browse(query: String, genre: String) -> Effect(Msg) { - browse_request(query, genre, None, GotBrowseSearch) + browse_request(query, genre, None, GotBrowseSearch(query, genre, _)) } pub fn load_browse_more( diff --git a/web/src/crate_web/msg.gleam b/web/src/crate_web/msg.gleam index 5a86214..185999c 100644 --- a/web/src/crate_web/msg.gleam +++ b/web/src/crate_web/msg.gleam @@ -171,11 +171,15 @@ pub type Msg { StartBatchImport BatchItemDone(barcode: String, result: Result(Nil, ApiError)) RetryBatchItem(barcode: String) - GotBrowse(Result(BrowseData, ApiError)) + GotBrowse(query: String, genre: String, result: Result(BrowseData, ApiError)) BrowseQuery(String) BrowseGenre(String) TriggerBrowseSearch - GotBrowseSearch(Result(BrowseData, ApiError)) + GotBrowseSearch( + query: String, + genre: String, + result: Result(BrowseData, ApiError), + ) BrowseShowMore GotBrowseMore( query: String, diff --git a/web/src/crate_web/update.gleam b/web/src/crate_web/update.gleam index b61ae12..2f63f04 100644 --- a/web/src/crate_web/update.gleam +++ b/web/src/crate_web/update.gleam @@ -109,11 +109,13 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { scan.batch_item_done(model, barcode, result) msg.RetryBatchItem(barcode) -> scan.retry_batch_item(model, barcode) - msg.GotBrowse(result) -> browse.got_browse(model, result) + msg.GotBrowse(query, genre, result) -> + browse.got_browse(model, query, genre, result) msg.BrowseQuery(value) -> browse.browse_query(model, value) msg.BrowseGenre(value) -> browse.browse_genre(model, value) msg.TriggerBrowseSearch -> browse.trigger_browse_search(model) - msg.GotBrowseSearch(result) -> browse.got_browse_search(model, result) + msg.GotBrowseSearch(query, genre, result) -> + browse.got_browse_search(model, query, genre, result) msg.BrowseShowMore -> browse.browse_show_more(model) msg.GotBrowseMore(query, genre, cursor, result) -> browse.got_browse_more(model, query, genre, cursor, result) diff --git a/web/src/crate_web/update/browse.gleam b/web/src/crate_web/update/browse.gleam index 8d68159..29c9303 100644 --- a/web/src/crate_web/update/browse.gleam +++ b/web/src/crate_web/update/browse.gleam @@ -17,10 +17,18 @@ import lustre/effect.{type Effect} pub fn got_browse( model: Model, + query: String, + genre: String, result: Result(msg.BrowseData, msg.ApiError), ) -> #(Model, Effect(Msg)) { - case result { - Ok(data) -> #( + // A slower unfiltered load landing after the viewer has already typed a + // filter must not stomp the filtered grid (or vice versa on clear). + let current = + string.trim(model.browse_query) == string.trim(query) + && string.trim(model.browse_genre) == string.trim(genre) + case current, result { + False, _ -> #(model, effect.none()) + True, Ok(data) -> #( Model( ..model, browse: data.releases, @@ -31,7 +39,7 @@ pub fn got_browse( effect.none(), ) // Only 401 means logged-out; keep auth on transient errors (network, 5xx). - Error(xrpc.BadStatus(status: 401, ..)) -> #( + True, Error(xrpc.BadStatus(status: 401, ..)) -> #( Model( ..model.set_crate(model, OwnCrate, ShelfLoaded(Own, [])), auth: LoggedOut, @@ -39,7 +47,7 @@ pub fn got_browse( ), effect.none(), ) - Error(xrpc.BadStatus(..)) -> #( + True, Error(xrpc.BadStatus(..)) -> #( Model( ..model, busy: False, @@ -47,7 +55,7 @@ pub fn got_browse( ), effect.none(), ) - Error(_) -> #( + True, Error(_) -> #( Model(..model, busy: False, notice: failed("Couldn't reach the server.")), effect.none(), ) @@ -97,10 +105,18 @@ pub fn trigger_browse_search(model: Model) -> #(Model, Effect(Msg)) { pub fn got_browse_search( model: Model, + query: String, + genre: String, result: Result(msg.BrowseData, msg.ApiError), ) -> #(Model, Effect(Msg)) { - case result { - Ok(data) -> #( + // Same staleness guard as `got_browse`: a search that resolves after the + // filters moved on (cleared, or edited again) must not land. + let current = + string.trim(model.browse_query) == string.trim(query) + && string.trim(model.browse_genre) == string.trim(genre) + case current, result { + False, _ -> #(model, effect.none()) + True, Ok(data) -> #( Model( ..model, browse: data.releases, @@ -111,7 +127,7 @@ pub fn got_browse_search( ) // A transient search failure keeps whatever's already on screen instead // of wiping the grid out from under the viewer mid-search. - Error(_) -> #( + True, Error(_) -> #( Model(..model, notice: failed("Couldn't search the shared catalog.")), effect.none(), ) diff --git a/web/test/browse_test.gleam b/web/test/browse_test.gleam index 54e67f0..eb7d38c 100644 --- a/web/test/browse_test.gleam +++ b/web/test/browse_test.gleam @@ -2,7 +2,7 @@ import crate/gen/catalog/list_releases.{ReleaseRow} import crate_web/model.{type BrowseRelease, Model} import crate_web/msg.{ BrowseAdd, BrowseClearFilters, BrowseData, BrowseShowMore, GotBrowse, - GotBrowseAdd, GotBrowseMore, ToggleBrowseFilters, + GotBrowseAdd, GotBrowseMore, GotBrowseSearch, ToggleBrowseFilters, } import crate_web/pages/browse import crate_web/update.{update} @@ -46,12 +46,61 @@ fn a_browse_release_with_genres( pub fn got_browse_stores_rows_test() { let rows = [a_browse_release("at://did:plc:abc/catalog.release/1")] let #(model, _) = - update(logged_in(), GotBrowse(Ok(BrowseData(rows, Some("next"))))) + update(logged_in(), GotBrowse("", "", Ok(BrowseData(rows, Some("next"))))) assert model.browse == rows assert model.browse_cursor == Some("next") assert model.busy == False } +pub fn stale_browse_load_is_discarded_once_a_filter_is_typed_test() { + let filtered = a_browse_release("at://uri/1") + let unfiltered = a_browse_release("at://uri/stale") + let seeded = Model(..logged_in(), browse: [filtered]) + let #(searching, _) = update(seeded, msg.BrowseQuery("slint")) + let #(model, _) = + update( + searching, + GotBrowse("", "", Ok(BrowseData([unfiltered], Some("stale-next")))), + ) + + assert model.browse == [filtered] + assert model.browse_cursor == None +} + +pub fn stale_browse_search_is_discarded_after_filters_change_again_test() { + let stale = a_browse_release("at://uri/stale") + let fresh = a_browse_release("at://uri/fresh") + let seeded = Model(..logged_in(), browse_query: "slint", browse: [fresh]) + let #(model, _) = + update( + seeded, + GotBrowseSearch( + "spiderland", + "", + Ok(BrowseData([stale], Some("stale-next"))), + ), + ) + + assert model.browse == [fresh] + assert model.browse_cursor == None +} + +pub fn stale_browse_search_is_discarded_after_clearing_filters_test() { + let stale = a_browse_release("at://uri/stale") + let cleared = a_browse_release("at://uri/cleared") + let filtered = Model(..logged_in(), browse_query: "slint", browse: []) + let #(seeded, _) = update(filtered, BrowseClearFilters) + let seeded = Model(..seeded, browse: [cleared]) + let #(model, _) = + update( + seeded, + GotBrowseSearch("slint", "", Ok(BrowseData([stale], Some("stale-next")))), + ) + + assert model.browse == [cleared] + assert model.browse_cursor == None +} + pub fn browse_show_more_marks_the_request_in_flight_test() { let seeded = Model(..logged_in(), browse_cursor: Some("next")) let #(model, _) = update(seeded, BrowseShowMore) -- 2.51.2