ML-169.10: rescope to list pages and use async handling

This commit is contained in:
Claudio Ortolina
2026-05-20 10:42:35 +01:00
parent d2def9d0d3
commit 760c44ccb5
@@ -1,9 +1,10 @@
--- ---
id: ML-169.10 id: ML-169.10
title: "Fix: Missing optimistic UI for delete operations" title: "Fix: Async optimistic UI for index/list delete streams"
status: To Do status: To Do
assignee: [] assignee: []
created_date: "2026-05-19 11:48" created_date: "2026-05-19 11:48"
updated_date: "2026-05-20 09:41"
labels: labels:
- perf - perf
- fix - fix
@@ -15,7 +16,8 @@ references:
Phase-4-Performance-Audit-Low-Hanging-Fruit-Findings.md Phase-4-Performance-Audit-Low-Hanging-Fruit-Findings.md
modified_files: modified_files:
- lib/music_library_web/live_helpers/index_actions.ex - lib/music_library_web/live_helpers/index_actions.ex
- lib/music_library_web/live/collection_live/show.ex - lib/music_library_web/live/collection_live/index.ex
- lib/music_library_web/live/wishlist_live/index.ex
- lib/music_library_web/live/scrobbled_tracks_live/index.ex - lib/music_library_web/live/scrobbled_tracks_live/index.ex
parent_task_id: ML-169 parent_task_id: ML-169
priority: medium priority: medium
@@ -26,34 +28,39 @@ ordinal: 31000
<!-- SECTION:DESCRIPTION:BEGIN --> <!-- SECTION:DESCRIPTION:BEGIN -->
Add optimistic UI for delete operations so the UI removes items immediately without waiting for the database transaction. Add optimistic UI for delete operations only where the item is rendered from a LiveView stream on an index/list page. The item should be removed from the stream immediately, while the database delete runs through LiveView-owned async handling so failures can be reported and rolled back.
## Problem ## Problem
All delete operations wait for the database transaction to complete before updating the UI. `stream_delete` is already used in some places, but the pattern is inconsistent. CollectionLive.Show deletes don't use `stream_delete` at all (they navigate away). Index/list delete handlers currently wait for the database delete before updating the UI. `stream_delete` is used after the delete succeeds, so the interaction can feel slower than necessary.
This task is intentionally scoped to stream-backed list views only:
- Collection index and wishlist index record streams via `IndexActions.handle_delete/2`
- Scrobbled tracks index stream
Show-page deletes are out of scope. `CollectionLive.Show` and `WishlistLive.Show` navigate away after delete and do not have a list stream to optimistically update. Their failure and rollback behavior should remain synchronous unless a separate navigation-specific design is created.
## Files ## Files
- `lib/music_library_web/live_helpers/index_actions.ex:94-98``handle_delete` - `lib/music_library_web/live_helpers/index_actions.ex` — remove record from `:records` immediately and start a LiveView async delete operation
- `lib/music_library_web/live/collection_live/show.ex:381-385``handle_event("delete")` - `lib/music_library_web/live/collection_live/index.ex` — handle async record delete success/failure for collection index
- `lib/music_library_web/live/scrobbled_tracks_live/index.ex:305-310``handle_event("delete")` - `lib/music_library_web/live/wishlist_live/index.ex` — handle async record delete success/failure for wishlist index
- `lib/music_library_web/live/scrobbled_tracks_live/index.ex` — remove track from `:tracks` immediately and handle async track delete success/failure
## Impact
Adds unnecessary latency to delete operations that could feel instant. While delete failures are rare, the benefit of snappy UI is worth the minimal risk.
## Fix ## Fix
Use `stream_delete` immediately, then perform the DB operation async. If DB fails, re-insert the item and show error toast. Use LiveView-owned async handling (`start_async/3` plus `handle_async/3`, or an equivalent pattern that sends results back to the LiveView process). Do not use bare `Task.start/1`, because it cannot update the socket, reinsert the stream item, or show an error toast.
```elixir Expected behavior:
def handle_delete(socket, id) do
record = Records.get_record!(id) 1. Fetch the item needed for rollback.
socket = stream_delete(socket, :records, record) 2. Immediately call `stream_delete/3` on the relevant stream.
Task.start(fn -> Records.delete_record(record) end) 3. Start an async delete operation owned by the LiveView.
{:noreply, socket} 4. On success, leave the stream as-is and optionally broadcast/refresh any cross-view state the existing design requires.
end 5. On failure or async exit, reinsert the original item into the stream and show a user-facing error toast.
```
For error text, use the projects existing user-facing error conventions (`ErrorMessages.friendly_message/1` where an error reason is shown).
<!-- SECTION:DESCRIPTION:END --> <!-- SECTION:DESCRIPTION:END -->
@@ -61,9 +68,10 @@ end
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Delete operations remove items from UI immediately (no DB wait) - [ ] #1 Index/list delete operations remove the item from the relevant stream before waiting for the database delete
- [ ] #2 Database delete still executes successfully in background - [ ] #2 Database delete still executes through LiveView-owned async handling, not bare `Task.start/1`
- [ ] #3 If DB delete fails, item is re-inserted into stream and error toast shown - [ ] #3 Async delete failure or exit reinserts the original item into the stream and shows an error toast
- [ ] #4 Existing delete confirmation modal still works correctly - [ ] #4 Existing delete confirmation prompts still work correctly
- [ ] #5 No orphaned UI state after failed delete - [ ] #5 Show-page delete handlers are not changed as part of this task
- [ ] #6 Collection and wishlist index delete behavior remains shared through `IndexActions` where practical
<!-- AC:END --> <!-- AC:END -->