Dismissible write failures, shape A2: the error state survives but its render site is gated by the same condition dismissal clears #877
Open
opened 2026-08-29 19:30:54 +02:00 by timothy
·
1 comment
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
release/v26.15.0-notes
fix/830-add-items-error-surface
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
renovate/dotnet-monorepo
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
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.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#877
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.
Split out of #830, which fixed one of the three shapes a call-site sweep found and named this one rather than folding twenty screens into that PR.
What #830 established
Dialog,SlideOverandConfirmDialogall dismiss through Escape, a backdrop/scrim click (useOverlayBehavior) and a header close button, and none of those consult a busy flag — disabling the footer Cancel button, which nearly every dialog here does, looks like it closes the hole and does not.A sweep of all 68
Dialog/ConfirmDialog/SlideOvercall sites (2026-08-29) found three shapes:{x && <Foo/>},props.openearly-return, or akey=remount), so the state dies with it. Fixed in #830 viauseDismissSafeErroratAddItemsDialogand the fourweb/src/media/addTo/dialogs.open/targetcondition the dismiss handler just cleared, so there is no DOM node left to render into. Same user-visible outcome as A1, different mechanism. This issue.role="alert"banner outside every dialog.DecoTemplatesScreenandBlocksScreenalready have this.The A2 shape, concretely
SettingsScreen.tsx:1198-1201is the clearest instance, and its own comment shows the tradeoff was considered:deleteTargetgates theConfirmDialog'smessage, so dismissing mid-DELETE nulls both the display condition and the message. The later.catchthen setsdeleteErroron an error nobody can ever see.CollectionsScreen.tsx:1090-1092is the same thing one level out — the caller force-nulls the child's error prop:Population (from the #830 sweep — verified vs. inferred is marked)
Verified by reading:
SettingsScreen.tsx:1185-1206,CollectionsScreen.tsx:1090-1092(create/rename/smart),PlayoutsScreen.tsx:119-133,259+,PlaylistsScreen.tsx:299-350,351-413,TraktListsScreen.tsx:91-142,ChannelBuilder.tsx:1587-1594,1625-1645,builder/SmartCollectionDialog.tsx:19-33.Matched structurally, NOT individually verified (
message={target ? … : ''}+onCancelnulling the same target):FFmpegProfilesScreen.tsx:452-465,FillerPresetsScreen.tsx:468-478,WatermarksScreen.tsx:333-346,SchedulesScreen.tsx:636-645,RemoteSourceScreen.tsx:202-215.Not triaged at all (~16 sites / 8 files, time-boxed out of the sweep):
MultiCollectionsScreen.tsx,RerunCollectionsScreen.tsx,PlayoutScheduleEditors.tsx,TrashScreen.tsx,PlexSourceScreen.tsx,schedules/ScheduleForm.tsx, and the editor-level dialogs inDecosScreen.tsx/TemplatesScreen.tsx.Treat that three-way split as the starting point, not the answer — re-derive the population from source, because an issue's file list is not the population.
Why it was not done in #830
#830's mechanism is a prop contract: the child reports to a caller-supplied
onFailed, and the parent decides where it lands. That is the right answer when the child owns the state. For A2 the state is already in the parent — the bug is purely that its render site is conditional — souseDismissSafeErroris the wrong tool and applying it would be cargo-culting.A2 is also dominated by delete-confirm flows, which are near-identical to each other. That suggests a shared answer (an unconditional screen-level banner region, or a small toast host wired at
AppShell) rather than 20 per-site prop threads — and that is a design decision worth its own review, not a widening of a bug fix.What to decide
AppShell, or a convention that every list screen carries an unconditionalrole="alert"region likeDecoTemplatesScreenalready does), or per-site fixes.Dialog/ConfirmDialogshould expose the busy state so dismissal can at least be reported rather than silently gated — noting #830 rejected BLOCKING dismissal on busy, because it traps the user behind an in-flight request with no cancel path.MediaDetailScreen'sAddToMenuusages (which wire neitheronDonenoronFailed) get a surface — a known, deliberate A1 gap left open by #830.Done-when
spa.dismissible-write-failure-reporting, or a new record superseding the relevant part of it)Related
#830 (A1 + the mechanism + the sweep), #740 (the async guards that made the drop deliberate),
docs/decisions/records/spa/dismissible-write-failure-reporting.md,docs/spa-conventions.md§3c.Scope grew: the four
media/addTo/dialogs (A1) are now here too, with measurements#830 originally fixed A1 at five sites. Two adversarial review rounds found the
media/addTo/four were not safe to fix in that PR, so they were withdrawn from it and belong here. What follows was all measured during #830 — it is evidence, not a plan.The
media/addTo/four gate BOTH halves of the outcomeAddToCollectionDialog,AddToPlaylistDialog,AddToScheduleDialog,SaveAsSmartCollectionDialogeach wrap the success callback in their own unmount guard:Measured on
AddToCollectionDialog(deferred POST, submit, unmount viaopen={false}, then settle):onAddedis called 0 times after dismissal. The other three carry a visibly identical gate — read, not probed. So dismiss-then-succeed is as silent as dismiss-then-fail: no confirmation Toast, and onSearchScreen/MediaBrowseScreenthe selection is never cleared becauseonAddedToSelectionTarget/onBulkAddednever run.AddToMenualso exposesonDoneand no failure counterpart at all, soAddToCollectionDialog'ssubmitErrorhas no route to the screen'sToasteven when the dialog is still open.Why the obvious fix is wrong — this is the part worth keeping
Removing that gate reports success correctly and also un-gates
onClose(). The two mean different things:onAdded/onDone— "tell the parent what happened". Must survive dismissal.onClose— "close me". After dismissal "me" is whatever the user opened next.Measured against the real
AddToMenu, with the gate removed: submit → Escape → user reopens the dialog to retry the add they think failed → the first POST returns 204 → the reopened dialog closes under them. OnSearchScreen/MediaBrowseScreenthe success handler also runsclearSelection(), wiping a multi-select the user had rebuilt.Also measured: gating
onClosealone does not fix it, becauseAddToMenu.handleAddeditself callssetDialog(null):The combination that measured clean was: gate only the close in the dialog (
onAdded?.(name); if (activeRef.current) { onClose(); }), and drop the now-redundantsetDialog(null)fromAddToMenu.handleAdded, and the same inSearchScreen.onAddedToSelectionTarget. That is three coupled edits across five files.And it leaves one genuine open question: should
clearSelection()fire for a write the user walked away from? The write succeeded, so the selection that produced it is stale — but the user may have built a different selection since. That is a product decision, which is why it did not ride along in a bug fix.What to do here
media/addTo/layer a failure channel (onFailedonAddToMenuand the four dialogs), usinguseDismissSafeErrorperspa.dismissible-write-failure-reportingonClose, moving the parents off their ownsetDialog(null)clearSelection()question explicitly and record itreportFailurein all four dialogs left the entire 1276-test suite green, so this layer currently asserts nothing about either direction. A witnessed-red test per direction, on at leastAddToCollectionDialogMediaDetailScreenwires neitheronDonenoronFailedat sevenAddToMenusites — decide whether it gets a surface or is recorded as deliberately silentAlso carried over from the #830 review
useIsMountedRefclears in a PASSIVE effect cleanup, so there is a narrow window where the DOM node is detached but the flag still readstrue— the message then renders inline into a dead tree instead of diverting.useLayoutEffectcloses it deterministically. Not done in #830 because that hook is shared by every async caller in the SPA (#578) and retiming it does not belong in a bug fix.CollectionsScreenusesrole="alert";Toastisrole="status"(polite) with a single last-writer-winsnoticeslot, so a later success can overwrite a pending failure. Worth settling if a shared surface is introduced.Related: #830 (the mechanism + the one fixed A1 site),
docs/decisions/records/spa/dismissible-write-failure-reporting.md,docs/spa-conventions.md§3c and §5c.