ML-188: guard PubSub record updates during edit mode
Add live_action guard to handle_info({:update, record}) in both
CollectionLive.Show and WishlistLive.Show. When the user is editing,
background worker updates are skipped and a warning toast is shown
instead of overwriting the socket. Normal show-mode updates and
mismatched-ID no-ops are unchanged.
6 tests added covering all three code paths.
This commit is contained in:
+27
-11
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: ML-188
|
id: ML-188
|
||||||
title: Fix form edit + background update race in Show LiveViews
|
title: Fix form edit + background update race in Show LiveViews
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: "2026-05-19 08:42"
|
created_date: "2026-05-19 08:42"
|
||||||
updated_date: "2026-05-19 09:00"
|
updated_date: "2026-05-19 10:18"
|
||||||
labels:
|
labels:
|
||||||
- audit
|
- audit
|
||||||
- bug
|
- bug
|
||||||
@@ -19,6 +19,10 @@ documentation:
|
|||||||
modified_files:
|
modified_files:
|
||||||
- lib/music_library_web/live/collection_live/show.ex
|
- lib/music_library_web/live/collection_live/show.ex
|
||||||
- lib/music_library_web/live/wishlist_live/show.ex
|
- lib/music_library_web/live/wishlist_live/show.ex
|
||||||
|
- lib/music_library_web/live_helpers/record_actions.ex
|
||||||
|
- test/music_library_web/live/collection_live/show_test.exs
|
||||||
|
- test/music_library_web/live/wishlist_live/show_test.exs
|
||||||
|
- docs/architecture.md
|
||||||
priority: medium
|
priority: medium
|
||||||
ordinal: 23000
|
ordinal: 23000
|
||||||
---
|
---
|
||||||
@@ -48,15 +52,15 @@ When a user is editing a record in a modal (`live_action == :edit`) and a backgr
|
|||||||
|
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
- [ ] #1 handle_info({:update, record}) skips assign(:record, ...) when live_action == :edit in CollectionLive.Show
|
- [x] #1 handle_info({:update, record}) skips assign(:record, ...) when live_action == :edit in CollectionLive.Show
|
||||||
- [ ] #2 handle_info({:update, record}) skips assign(:record, ...) when live_action == :edit in WishlistLive.Show
|
- [x] #2 handle_info({:update, record}) skips assign(:record, ...) when live_action == :edit in WishlistLive.Show
|
||||||
- [ ] #3 Warning toast shown to user when background update occurs during edit (different wording from normal info toast)
|
- [x] #3 Warning toast shown to user when background update occurs during edit (different wording from normal info toast)
|
||||||
- [ ] #4 handle_info({:update, record}) still works normally when live_action == :show
|
- [x] #4 handle_info({:update, record}) still works normally when live_action == :show
|
||||||
- [ ] #5 When user navigates away from edit, handle_params re-fetches fresh record with worker changes
|
- [x] #5 When user navigates away from edit, handle_params re-fetches fresh record with worker changes
|
||||||
- [ ] #6 Mismatched record.id still handled as no-op (socket unchanged)
|
- [x] #6 Mismatched record.id still handled as no-op (socket unchanged)
|
||||||
- [ ] #7 Tests added for all three cases: :edit guard, :show normal path, mismatched-id no-op
|
- [x] #7 Tests added for all three cases: :edit guard, :show normal path, mismatched-id no-op
|
||||||
- [ ] #8 docs/architecture.md updated to mention live_action guard
|
- [x] #8 docs/architecture.md updated to mention live_action guard
|
||||||
- [ ] #9 Comment added in RecordActions.handle_record_updated/2 noting intentional bypass during :edit
|
- [x] #9 Comment added in RecordActions.handle_record_updated/2 noting intentional bypass during :edit
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
@@ -117,3 +121,15 @@ Run: `mix test test/music_library_web/live/collection_live/show_test.exs test/mu
|
|||||||
- Update `docs/architecture.md`: the existing line about `handle_info/2` validating inbound record updates against `socket.assigns.record.id` should also mention the `live_action` guard for form-edit safety
|
- Update `docs/architecture.md`: the existing line about `handle_info/2` validating inbound record updates against `socket.assigns.record.id` should also mention the `live_action` guard for form-edit safety
|
||||||
- Update the task's final summary with a brief note about the design decision
|
- Update the task's final summary with a brief note about the design decision
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Added `live_action` guard to `handle_info({:update, record})` in both CollectionLive.Show and WishlistLive.Show. When the user is editing a record (`live_action == :edit`), background worker updates are skipped and a distinct warning toast ("Your edits may be stale — save and re-open...") is shown instead of silently overwriting the socket. When showing (`live_action == :show`), existing behavior is unchanged — the record updates and shows "Record updated in the background". Mismatched-ID records remain a no-op.
|
||||||
|
|
||||||
|
This prevents the race where a background worker (PopulateGenres, RefreshCover, etc.) updates the DB and broadcasts `{:update, v2}` while the user's edit modal is open with v1. The guard keeps `@record = v1` on the socket so the form saves only user-changed fields via Ecto changeset semantics. When the user navigates away from edit, `handle_params` re-fetches the fresh record with worker changes.
|
||||||
|
|
||||||
|
6 tests added (3 per LiveView), covering: `:show` normal path, `:edit` guard path, and mismatched-ID no-op. Architecture docs updated with guard description.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -250,11 +250,11 @@ HTTP 429 into `:rate_limit` vs `:auth_error` by reading the body `code`
|
|||||||
|
|
||||||
## PubSub Topics
|
## PubSub Topics
|
||||||
|
|
||||||
| PubSub | Topic Pattern | Message | Used By |
|
| PubSub | Topic Pattern | Message | Used By |
|
||||||
| ---------------- | -------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------- |
|
| ---------------- | -------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `:music_library` | `"records:#{id}"` | `{:update, record}` | CollectionLive.Show, WishlistLive.Show — subscribe in handle_params, unsubscribe on navigation |
|
| `:music_library` | `"records:#{id}"` | `{:update, record}` | CollectionLive.Show, WishlistLive.Show — subscribe in `handle_params`, unsubscribe on navigation. `handle_info({:update, record})` matches record ID against socket and guards against applying background updates during `:edit` `live_action` (skips assign, shows warning toast) to prevent worker changes from overwriting in-progress form edits |
|
||||||
| `:music_library` | `"records:index_changed"` | `:records_index_changed` | CollectionLive.Index, WishlistLive.Index — auto-refresh when background import completes |
|
| `:music_library` | `"records:index_changed"` | `:records_index_changed` | CollectionLive.Index, WishlistLive.Index — auto-refresh when background import completes |
|
||||||
| `:music_library` | `"listening_stats:update"` | `%{track_count: n}` | StatsLive.Index, ScrobbledTracksLive.Index — new scrobbles arrived |
|
| `:music_library` | `"listening_stats:update"` | `%{track_count: n}` | StatsLive.Index, ScrobbledTracksLive.Index — new scrobbles arrived |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -472,13 +472,25 @@ defmodule MusicLibraryWeb.CollectionLive.Show do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:update, record}, socket) do
|
def handle_info({:update, record}, socket) do
|
||||||
if record.id == socket.assigns.record.id do
|
cond do
|
||||||
{:noreply,
|
record.id != socket.assigns.record.id ->
|
||||||
socket
|
{:noreply, socket}
|
||||||
|> RecordActions.handle_record_updated(record)
|
|
||||||
|> assign_similar_records()}
|
socket.assigns.live_action == :edit ->
|
||||||
else
|
{:noreply,
|
||||||
{:noreply, socket}
|
socket
|
||||||
|
|> put_toast(
|
||||||
|
:warning,
|
||||||
|
gettext(
|
||||||
|
"Record was updated in the background. Your edits may be stale — save and re-open to see the latest data."
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> RecordActions.handle_record_updated(record)
|
||||||
|
|> assign_similar_records()}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -365,10 +365,22 @@ defmodule MusicLibraryWeb.WishlistLive.Show do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:update, record}, socket) do
|
def handle_info({:update, record}, socket) do
|
||||||
if record.id == socket.assigns.record.id do
|
cond do
|
||||||
{:noreply, RecordActions.handle_record_updated(socket, record)}
|
record.id != socket.assigns.record.id ->
|
||||||
else
|
{:noreply, socket}
|
||||||
{:noreply, socket}
|
|
||||||
|
socket.assigns.live_action == :edit ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_toast(
|
||||||
|
:warning,
|
||||||
|
gettext(
|
||||||
|
"Record was updated in the background. Your edits may be stale — save and re-open to see the latest data."
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{:noreply, RecordActions.handle_record_updated(socket, record)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ defmodule MusicLibraryWeb.LiveHelpers.RecordActions do
|
|||||||
@doc """
|
@doc """
|
||||||
Handles a background record update. Returns the updated socket (not wrapped
|
Handles a background record update. Returns the updated socket (not wrapped
|
||||||
in `{:noreply, ...}`) so the caller can pipe additional assigns.
|
in `{:noreply, ...}`) so the caller can pipe additional assigns.
|
||||||
|
|
||||||
|
**Note:** Callers intentionally bypass this function when `live_action == :edit`
|
||||||
|
to prevent background worker updates from overwriting the user's in-progress
|
||||||
|
form edits. See CollectionLive.Show and WishlistLive.Show `handle_info({:update, record})`.
|
||||||
"""
|
"""
|
||||||
def handle_record_updated(socket, record) do
|
def handle_record_updated(socket, record) do
|
||||||
socket
|
socket
|
||||||
|
|||||||
@@ -2556,3 +2556,9 @@ msgstr ""
|
|||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "ISO format"
|
msgid "ISO format"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/live/collection_live/show.ex
|
||||||
|
#: lib/music_library_web/live/wishlist_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Record was updated in the background. Your edits may be stale — save and re-open to see the latest data."
|
||||||
|
msgstr ""
|
||||||
|
|||||||
@@ -2556,3 +2556,9 @@ msgstr ""
|
|||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "ISO format"
|
msgid "ISO format"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/live/collection_live/show.ex
|
||||||
|
#: lib/music_library_web/live/wishlist_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Record was updated in the background. Your edits may be stale — save and re-open to see the latest data."
|
||||||
|
msgstr ""
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ defmodule MusicLibraryWeb.CollectionLive.ShowTest do
|
|||||||
alias MusicBrainz.Fixtures
|
alias MusicBrainz.Fixtures
|
||||||
alias MusicLibrary.Assets.Transform
|
alias MusicLibrary.Assets.Transform
|
||||||
alias MusicLibrary.Records.Record
|
alias MusicLibrary.Records.Record
|
||||||
|
alias Phoenix.PubSub
|
||||||
|
|
||||||
describe "Edit record from show page" do
|
describe "Edit record from show page" do
|
||||||
test "can navigate to the record edit form", %{conn: conn} do
|
test "can navigate to the record edit form", %{conn: conn} do
|
||||||
@@ -68,6 +69,93 @@ defmodule MusicLibraryWeb.CollectionLive.ShowTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "handle_info({:update, record}) with live_action guard" do
|
||||||
|
test "updates record when showing (live_action is :show)", %{conn: conn} do
|
||||||
|
record = record()
|
||||||
|
|
||||||
|
release_response = Fixtures.Release.release(:marbles)
|
||||||
|
|
||||||
|
Req.Test.stub(MusicBrainz.API, fn conn ->
|
||||||
|
Req.Test.json(conn, release_response)
|
||||||
|
end)
|
||||||
|
|
||||||
|
updated_record = %{record | title: "Background Updated Title"}
|
||||||
|
|
||||||
|
session =
|
||||||
|
conn
|
||||||
|
|> visit(~p"/collection/#{record.id}")
|
||||||
|
|> render_async()
|
||||||
|
|
||||||
|
PubSub.broadcast(
|
||||||
|
MusicLibrary.PubSub,
|
||||||
|
"records:#{record.id}",
|
||||||
|
{:update, updated_record}
|
||||||
|
)
|
||||||
|
|
||||||
|
session
|
||||||
|
|> assert_has("*", text: "Background Updated Title", timeout: 200)
|
||||||
|
|> assert_has("#toast-group", text: "Record updated in the background")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "skips update when editing and shows warning toast", %{conn: conn} do
|
||||||
|
record = record()
|
||||||
|
|
||||||
|
release_response = Fixtures.Release.release(:marbles)
|
||||||
|
|
||||||
|
Req.Test.stub(MusicBrainz.API, fn conn ->
|
||||||
|
Req.Test.json(conn, release_response)
|
||||||
|
end)
|
||||||
|
|
||||||
|
updated_record = %{record | title: "Should Not Appear"}
|
||||||
|
|
||||||
|
session =
|
||||||
|
conn
|
||||||
|
|> visit(~p"/collection/#{record.id}")
|
||||||
|
|> render_async()
|
||||||
|
|> click_link("Edit")
|
||||||
|
|
||||||
|
PubSub.broadcast(
|
||||||
|
MusicLibrary.PubSub,
|
||||||
|
"records:#{record.id}",
|
||||||
|
{:update, updated_record}
|
||||||
|
)
|
||||||
|
|
||||||
|
session
|
||||||
|
|> refute_has("h2", text: "Should Not Appear")
|
||||||
|
|> assert_has(
|
||||||
|
"#toast-group",
|
||||||
|
text:
|
||||||
|
"Record was updated in the background. Your edits may be stale — save and re-open to see the latest data."
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no-ops when broadcasted record has mismatched ID", %{conn: conn} do
|
||||||
|
record = record()
|
||||||
|
other_record = record()
|
||||||
|
|
||||||
|
release_response = Fixtures.Release.release(:marbles)
|
||||||
|
|
||||||
|
Req.Test.stub(MusicBrainz.API, fn conn ->
|
||||||
|
Req.Test.json(conn, release_response)
|
||||||
|
end)
|
||||||
|
|
||||||
|
session =
|
||||||
|
conn
|
||||||
|
|> visit(~p"/collection/#{record.id}")
|
||||||
|
|> render_async()
|
||||||
|
|
||||||
|
PubSub.broadcast(
|
||||||
|
MusicLibrary.PubSub,
|
||||||
|
"records:#{record.id}",
|
||||||
|
{:update, other_record}
|
||||||
|
)
|
||||||
|
|
||||||
|
session
|
||||||
|
|> assert_has("h2", text: escape(record.title))
|
||||||
|
|> refute_has("#toast-group", text: "Record updated in the background")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "Side panel" do
|
describe "Side panel" do
|
||||||
test "shows a record's tracks", %{conn: conn} do
|
test "shows a record's tracks", %{conn: conn} do
|
||||||
record = record()
|
record = record()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ defmodule MusicLibraryWeb.WishlistLive.ShowTest do
|
|||||||
|
|
||||||
alias MusicLibrary.Assets.Transform
|
alias MusicLibrary.Assets.Transform
|
||||||
alias MusicLibrary.Records.Record
|
alias MusicLibrary.Records.Record
|
||||||
|
alias Phoenix.PubSub
|
||||||
|
|
||||||
describe "Edit record from show page" do
|
describe "Edit record from show page" do
|
||||||
test "can navigate to the record edit form", %{conn: conn} do
|
test "can navigate to the record edit form", %{conn: conn} do
|
||||||
@@ -51,4 +52,68 @@ defmodule MusicLibraryWeb.WishlistLive.ShowTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "handle_info({:update, record}) with live_action guard" do
|
||||||
|
test "updates record when showing (live_action is :show)", %{conn: conn} do
|
||||||
|
record = record(purchased_at: nil)
|
||||||
|
updated_record = %{record | title: "Background Updated Title"}
|
||||||
|
|
||||||
|
session =
|
||||||
|
conn
|
||||||
|
|> visit(~p"/wishlist/#{record.id}")
|
||||||
|
|
||||||
|
PubSub.broadcast(
|
||||||
|
MusicLibrary.PubSub,
|
||||||
|
"records:#{record.id}",
|
||||||
|
{:update, updated_record}
|
||||||
|
)
|
||||||
|
|
||||||
|
session
|
||||||
|
|> assert_has("*", text: "Background Updated Title", timeout: 200)
|
||||||
|
|> assert_has("#toast-group", text: "Record updated in the background")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "skips update when editing and shows warning toast", %{conn: conn} do
|
||||||
|
record = record(purchased_at: nil)
|
||||||
|
updated_record = %{record | title: "Should Not Appear"}
|
||||||
|
|
||||||
|
session =
|
||||||
|
conn
|
||||||
|
|> visit(~p"/wishlist/#{record.id}")
|
||||||
|
|> click_link("Edit")
|
||||||
|
|
||||||
|
PubSub.broadcast(
|
||||||
|
MusicLibrary.PubSub,
|
||||||
|
"records:#{record.id}",
|
||||||
|
{:update, updated_record}
|
||||||
|
)
|
||||||
|
|
||||||
|
session
|
||||||
|
|> refute_has("h2", text: "Should Not Appear")
|
||||||
|
|> assert_has(
|
||||||
|
"#toast-group",
|
||||||
|
text:
|
||||||
|
"Record was updated in the background. Your edits may be stale — save and re-open to see the latest data."
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no-ops when broadcasted record has mismatched ID", %{conn: conn} do
|
||||||
|
record = record(purchased_at: nil)
|
||||||
|
other_record = record(purchased_at: nil)
|
||||||
|
|
||||||
|
session =
|
||||||
|
conn
|
||||||
|
|> visit(~p"/wishlist/#{record.id}")
|
||||||
|
|
||||||
|
PubSub.broadcast(
|
||||||
|
MusicLibrary.PubSub,
|
||||||
|
"records:#{record.id}",
|
||||||
|
{:update, other_record}
|
||||||
|
)
|
||||||
|
|
||||||
|
session
|
||||||
|
|> assert_has("h2", text: escape(record.title))
|
||||||
|
|> refute_has("#toast-group", text: "Record updated in the background")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user