AddItemsDialog: a failed add is silently swallowed when the dialog is closed mid-request #830
Closed
opened 2026-08-25 23:24:21 +02:00 by timothy
·
2 comments
No Branch/Tag Specified
main
renovate/lucene.net
renovate/dotnet-monorepo
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
renovate/meziantou.analyzer-3.x
renovate/cliwrap-3.x
913-scripted-playout-401
release/v26.15.0-notes
issue-806-guard-populations
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.16.0
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#830
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Surfaced by the independent review of #740 (PR for that issue). Pre-existing and NOT fixed by #740 — that issue added the is-mounted guard, which correctly stops a setState on an unmounted tree; it deliberately does not change the UX, and should not have.
The defect
AddItemsDialog.submit(web/src/screens/CollectionsScreen.tsx) POSTs to/api/v1/collections/{id}/itemsand reports failure by rendering an error banner inside the dialog. The dialog is closable mid-request: the footer Cancel button isdisabled={adding}, butDialogcloses on Escape, on a backdrop click and via the header close button, and none of those consultadding(web/src/components/overlay.tsx). Closing unmounts the instance — the caller renders it underkey={`add-${pickerOpen}`}.So: select 12 items → Add → press Escape → the request fails. There is no longer a dialog to render the error into, nothing else surfaces it, and the collection list reloads unchanged. The user believes 12 items were added and nothing was.
The write itself is not lost or partial — it simply never happened, and the UI does not say so.
Why it was left out of #740
#740's scope is the two async guards §3b mandates. Guarding
setErrorthere makes the drop deliberate rather than accidental, which is correct — the fix for the missing feedback is a different mechanism (one that outlives the dialog), not a guard. Bundling a notification surface into that PR would have widened it well past its issue.What to decide
The error has to survive the dialog. Options, in rough order of size:
adding, the way the Cancel button already is. Smallest diff, but it traps the user behind a request with no cancel path, which is whyDialogdoesn't do this by default.CollectionsScreenso the banner renders where the user actually is. Consistent with the collection screen already owningmessageFromCollectionErroroutput.Option 2 looks right, but the pattern should be checked against how other screens report a write failure whose originating surface can close, since this is unlikely to be the only instance — sweep for it rather than fixing this one site (
fix-the-boundary-not-the-site).Done-when
Resolution — option 2, and option 1 was tested and rejected. Gating dismissal on
addingtraps the user behind an in-flight request with no cancel path, and would not cancel the write anyway. There is no toast host in this SPA (option 3), so none was added for one screen. The mechanism isuseDismissSafeError(web/src/hooks.ts): inline while mounted, diverted to a caller-supplied reporter once dismissed. PR #878, recordspa.dismissible-write-failure-reporting,docs/spa-conventions.md§3c.Box 2 — the sweep ran and its answer was "reuse it at ONE site, record the rest". All 68
Dialog/ConfirmDialog/SlideOvercall sites were triaged into three shapes: (A1) the surface owns the error state and really unmounts; (A2) the state survives but its render site is gated by the same condition dismissal clears — the majority, mostly delete-confirm flows; (B) an already-correct screen-level banner. Only A1 atAddItemsDialogis fixed here. The fourmedia/addTo/dialogs are the same shape and were fixed, then withdrawn after review measured the extension unsafe twice — see #877, which carries every measurement. A2 is #877 as well.Box 3 — witnessed, and the split is pinned in both directions. Disarming
reportRef.current(message)reddens the integration test onUnable to find an element with the text: Request failed with status 500. Separately: deleting theif (mountedRef.current)aroundonClose()reddens only the reopen test; deleting theonClose()call entirely reddens only the ordinary-close assertion. The two are orthogonal — neither masks the other.Box 4 — seven rounds, and the review changed the shipped result four times. It measured my central prose claim FALSE, caught a regression my own fix introduced, found three sentences that outlived the code they described, one coverage claim this record's own
mechanics:field contradicted, and a convention that contradicted its own named exemplar. Recorded because the last two are the same shape: after a retraction, the retracted WORDING has to be swept, not just the code. Caveat stated rather than implied: the cross-family (Codex) pass exhausted its quota mid-read and produced no findings, so this was a same-family cold, worktree-isolated reviewer.Related
#740 (the guards, which made this drop deliberate), #685 (the AddItemsDialog work that preceded it), #877 (the withdrawn A1 half + all of A2),
docs/spa-conventions.md§3b.Claiming. Claude Code / Opus 5, orchestrator tier. Worktree
~/ersatztv-wt/830-add-items-error-surface, branchfix/830-add-items-error-surfaceoff a freshorigin/main(8aeacd534).Pre-claim checks (
process.parallel-session-claim), all four clear as of 2026-08-29 19:00 CEST: no open PR body references #830 (the five open PRs are all Renovate);git ls-remote --heads originhas no branch naming 830 oradditems; the issue had zero comments, so no claim predates the label;git fetch origin mainis current.Context for whoever reads this next: three other sessions are live on this repo right now — #823+#824 (worktree
823-824-701-deferrals), #820 (main-3), #812 (main-2). Those four are the entirein-progressset, so #830 does not overlap any of them.Scope note on done-when box 2 (the sweep): the issue asks that the chosen mechanism be checked against other write paths whose originating surface can close mid-request, per
fix-the-boundary-not-the-site. I am treating that sweep as in-scope for this PR rather than a follow-up, and will derive the population of such surfaces from source rather than listing them by hand — an issue's own file list is not the population.Closing record
Outcome: Shipped in PR #878 (fixes #830, refs #877/#740/#685) — head
e1d211cd1.AddItemsDialog's failed add now reports throughuseDismissSafeError(web/src/hooks.ts): inline, inside the still-open dialog, while it is mounted, and via a caller-suppliedonFailedtoCollectionsScreen's screen-level banner once it is not.Root cause:
Dialogdismisses through three paths (Escape, backdrop click, header close) that never consult theaddingbusy flag, and the caller remounts the dialog on a keyed prop, so dismissal genuinely unmounts the component holding the error banner. A write failure after that point had nowhere left to render.Decisions/conventions changed: New record
spa.dismissible-write-failure-reporting(docs/decisions/records/spa/) +docs/spa-conventions.md§3c/§5c — the report/dismiss split (onFailedreports an outcome,onCloserequests a dismissal; gate the latter on mount, never the former) and which arm of the hook a call site must reach when the surface is still open.Reusable knowledge: A callback that reports an outcome and one that requests a dismissal are not interchangeable for async-guard purposes — gating both drops a completed write silently; gating neither reopens the discarded-selection bug. The four
web/src/media/addTo/dialogs share this shape but gate both halves together in a way this fix does not generalize to safely; tracked separately as #877 rather than folded in here.Verification:
npm run check:api && lint && typecheck && build && test(1325 vitest passed, 121 files),pytest scripts/tests(1569 passed, 3 skipped),check-doc-narrative.pyclean, decisions catalog regenerates with no drift. Rebased twice onto a movingorigin/main(latest merge-base92db6dc81) with the code diff verified byte-identical each time; both rebases hit zero conflicts. CI armed one1d211cd1(PR #878).Deferred: The
web/src/media/addTo/dialogs (shape A2) — tracked as #877, with the two rejected extension attempts and their measurements recorded there and in the decision record.Docs updated:
docs/decisions/records/spa/dismissible-write-failure-reporting.md(new) + regenerateddocs/decisions/README.md;docs/spa-conventions.md§3c/§5c.timothy referenced this issue2026-09-05 02:12:51 +02:00