Skip to content

General: Smoother multi-select on tool lists#2477

Merged
d4rken merged 1 commit into
mainfrom
perf/selection-churn
Jun 22, 2026
Merged

General: Smoother multi-select on tool lists#2477
d4rken merged 1 commit into
mainfrom
perf/selection-churn

Conversation

@d4rken

@d4rken d4rken commented Jun 21, 2026

Copy link
Copy Markdown
Member

What changed

When you select multiple items in a tool's list or detail screen (apps, files, duplicates, exclusions…), tapping each item used to make the whole visible list redo work for every row — re-loading thumbnails and re-formatting sizes/dates/counts on every tap. On lower-end devices that showed up as lag while multi-selecting. Now tapping an item only updates that one row, so multi-select stays smooth even on long lists. Nothing about how selection looks or behaves changes.

Technical Context

  • Root cause: every list/detail screen read the selection Set (selection.contains(id) / selection.isNotEmpty()) inside its items{} lambda, so every visible row subscribed to the single MutableState<Set<T>> and recomposed on every toggle — re-running its leaf work (thumbnails, formatShortFileSize, plurals, date formatters).
  • Fix: new @Stable SelectionState<T> holder (app-common-ui/.../compose/selection/) exposing per-id membership via @Composable isSelected(id) backed by derivedStateOf, so only the row whose membership flips recomposes. isActive/count are derivedStateOf too, so the container/top bar recompose only on the selection-mode transition / count change, not on every toggle. Replaces and deletes the old rememberSelection helper.
  • Scope: all 13 call sites migrated. Screen-scope aggregate reads that would have re-invalidated the list (Squeezer "savings", Analyzer "selected items / none inaccessible") were moved into the top-bar composables. Container composables CorpseContent / AppJunkPage / FilterContentPage / ClusterContent now take the holder. AppControl's pendingExportIds was migrated too so the old helper had no remaining callers.
  • Deduplicator (data safety): the details screen's keep-one delete cap is preserved on toggle/long-press/select-all, and is now also re-applied after the live-id prune — closing a pre-existing gap where a cluster shrinking under a held selection (or a saved selection restored against a smaller cluster) could let a delete take the entire cluster. Verified on-device: a 3-item cluster caps selection at 2.
  • Out of scope: the Deduplicator list selectedDupes delete-target model (a separate VM-owned mechanism, not rememberSelection).

Review guidance

  • The isolation contract lives in SelectionState.isSelectedSelectionStateIsolationTest proves a steady-active toggle recomposes only the toggled row, and documents the expected all-row recompose on the empty↔non-empty transition.
  • The non-mechanical migration is DeduplicatorDetailsScreen + ClusterContent (cluster clip via selectionEnabled && isSelected, the cap re-application after prune).

Verification

  • New SelectionState unit tests + Robolectric recomposition-isolation test.
  • 1,768 existing unit tests pass across the 10 touched modules; full FOSS debug build green.
  • Codex plan review + implementation review (the cap-after-prune fix came from the impl review).
  • On-device Pixel 7a: multi-select + select-all + clear on Deduplicator/AppControl/AppCleaner/SystemCleaner, keep-one cap holds, 0 crashes/ANRs.

Replace rememberSelection<T>() (MutableState<Set<T>>) with a @stable
SelectionState<T> holder exposing per-id membership via @composable
isSelected(id) backed by derivedStateOf, plus isActive/count as
derivedStateOf. Toggling one row now recomposes only that row instead of
the whole visible window: previously every visible item subscribed to the
single Set state and re-ran its leaf work (thumbnails, file-size/date
formatting, plurals) on every toggle.

Migrated all 13 call sites across corpsefinder, systemcleaner,
deduplicator, analyzer, squeezer, appcontrol, appcleaner, exclusion and
swiper. Screen-scope aggregate reads (Squeezer savings, Analyzer
selected-items/none-inaccessible) moved into their top-bar composables so
they no longer re-invalidate the list. Container composables
(CorpseContent, AppJunkPage, FilterContentPage, ClusterContent) now take
the holder. AppControl pendingExportIds migrated too, so the old helper
could be deleted.

Deduplicator details: preserved the keep-one delete cap on
toggle/long-press/select-all, and additionally re-apply it after the prune
so a cluster shrinking under a held selection (or a restored selection
against a smaller cluster) can no longer let a delete take the whole
cluster.

Adds SelectionState unit tests plus a Robolectric recomposition-isolation
test proving a steady-active toggle recomposes only the toggled row.
Base automatically changed from compose-rewrite to main June 22, 2026 13:54
@d4rken d4rken marked this pull request as ready for review June 22, 2026 15:01
@d4rken d4rken closed this Jun 22, 2026
@d4rken d4rken reopened this Jun 22, 2026
@d4rken d4rken merged commit 00ecef8 into main Jun 22, 2026
24 of 25 checks passed
@d4rken d4rken deleted the perf/selection-churn branch June 22, 2026 16:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant