fix(830): a write failure reports to a surface that outlives the dialog that started it (#878)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 17s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 13s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m54s

fixes #830

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
This commit was merged in pull request #878.
This commit is contained in:
2026-09-04 23:02:44 +00:00
co-authored by Claude Fable 5.1
8 changed files with 512 additions and 31 deletions
+1
View File
@@ -195,6 +195,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `spa.collection-custom-order-ui` | Collection custom ordering uses per-row Move up/Move down buttons (not drag) and is offered for any manual collection with custom order enabled, not just movies-only. | 2026-07-09 | [link](records/spa/collection-custom-order-ui.md) |
| `spa.datetime-local-input` | The channel-mode date/time input uses a native `<input type="datetime-local">` instead of free-text Chronic natural-language parsing. | 2026-07-09 | [link](records/spa/datetime-local-input.md) |
| `spa.deco-templates-table` | The deco-templates editor also renders its day/deco assignment as a table, extending (not replacing) the templates-editor-table convention. | 2026-07-09 | [link](records/spa/deco-templates-table.md) |
| `spa.dismissible-write-failure-reporting` | A component that starts an async WRITE from inside a dismissible surface (`Dialog`, `SlideOver`, `ConfirmDialog`) must report failure through `useDismissSafeError` (`web/src/hooks.ts`), which renders the message INLINE while the surface is mounted and hands it to a caller-supplied `onFailed` once the surface is gone. Guarding the `setError` with an is-mounted check and stopping there is NOT sufficient: it converts a silent data-loss risk into a silent NO-OP the user reads as success. All three primitives dismiss through Escape and a backdrop/scrim click (`useOverlayBehavior`) plus a header close button, and NONE of those consult a busy flag — disabling the footer Cancel button, which every one of these dialogs does, looks like it closes the hole and does not. Report the OUTCOME past dismissal in both directions — a success callback gated on an is-mounted check makes a completed write silent too (measured on `AddToCollectionDialog`, #877) — but gate the DISMISS request (`onClose`) separately, because closing a surface that is no longer yours closes whatever replaced it. The surviving surface belongs to the PARENT, so the mechanism is a PROP CONTRACT rather than a rendering decision; as of 2026-08-29 exactly ONE screen is wired (`CollectionsScreen`, into its screen-level `role="alert"` banner), and the `notice`+`Toast` pair on `MediaBrowseScreen`/`SearchScreen` is a CANDIDATE second surface, not a wired one; there is no global toast host in this SPA and this decision does not add one. The reporting prop is REQUIRED where the host has a surface (`AddItemsDialog.onAddFailed`), so a failure cannot be dropped by forgetting to wire it; it is optional only where some host genuinely has nowhere to report, and there an omitted callback drops the failure exactly as before — a KNOWN remaining gap, not a claim of coverage. | 2026-08-29 | [link](records/spa/dismissible-write-failure-reporting.md) |
| `spa.download-sample-gate` | The SPA disables both Download Media Sample and Download Results while a troubleshooting session is starting/running (Blazor only gated Download Results). | 2026-07-09 | [link](records/spa/download-sample-gate.md) |
| `spa.field-progressive-disclosure` | A consequential settings field explains itself through one shared `FieldHelp` icon trigger beside the field name — never the label itself, never a widened `Tooltip` — with the paragraph declared `as const` in the screen's own `FIELD_HELP` record and the panel portalled to `document.body`. | 2026-08-26 | [link](records/spa/field-progressive-disclosure.md) |
| `spa.legacy-redirect-matcher` | `LegacyUiRedirects.TryGetRedirect` is a two-tier matcher — an exact `OrdinalIgnoreCase` `Map` (Tier 1) then an ordered segment-template pattern list (Tier 2, first-match-wins) — collision-free by construction, with a guard invariant that no rule may prefix-match `/api`, `/artwork`, `/docs`, `/openapi`, `/iptv`, `/app`, or `/media/sources`. | 2026-07-11 | [link](records/spa/legacy-redirect-matcher.md) |
@@ -0,0 +1,100 @@
---
key: spa.dismissible-write-failure-reporting
title: '2026-08-29 — A write failure reports to a surface that OUTLIVES the dismissible surface that started it (#830)'
status: active
since: '2026-08-29'
supersedes: none
superseded-by: none
rule: 'A component that starts an async WRITE from inside a dismissible surface (`Dialog`, `SlideOver`, `ConfirmDialog`) must report failure through `useDismissSafeError` (`web/src/hooks.ts`), which renders the message INLINE while the surface is mounted and hands it to a caller-supplied `onFailed` once the surface is gone. Guarding the `setError` with an is-mounted check and stopping there is NOT sufficient: it converts a silent data-loss risk into a silent NO-OP the user reads as success. All three primitives dismiss through Escape and a backdrop/scrim click (`useOverlayBehavior`) plus a header close button, and NONE of those consult a busy flag — disabling the footer Cancel button, which every one of these dialogs does, looks like it closes the hole and does not. Report the OUTCOME past dismissal in both directions — a success callback gated on an is-mounted check makes a completed write silent too (measured on `AddToCollectionDialog`, #877) — but gate the DISMISS request (`onClose`) separately, because closing a surface that is no longer yours closes whatever replaced it. The surviving surface belongs to the PARENT, so the mechanism is a PROP CONTRACT rather than a rendering decision; as of 2026-08-29 exactly ONE screen is wired (`CollectionsScreen`, into its screen-level `role="alert"` banner), and the `notice`+`Toast` pair on `MediaBrowseScreen`/`SearchScreen` is a CANDIDATE second surface, not a wired one; there is no global toast host in this SPA and this decision does not add one. The reporting prop is REQUIRED where the host has a surface (`AddItemsDialog.onAddFailed`), so a failure cannot be dropped by forgetting to wire it; it is optional only where some host genuinely has nowhere to report, and there an omitted callback drops the failure exactly as before — a KNOWN remaining gap, not a claim of coverage.'
signals: 'failed add silently swallowed · dialog closed mid-request · Escape backdrop close button ignore adding flag · error banner unmounts with the dialog · useDismissSafeError inline vs onFailed · success outlives dismissal but failure does not · onDone has no failure counterpart · paths: `web/src/hooks.ts`, `web/src/screens/CollectionsScreen.tsx`, `web/src/media/addTo/`, `web/src/components/overlay.tsx` · issues: #830, #877, #740, #685'
mechanics: 'Pinned five ways. (1) `web/src/hooks.test.tsx` → "useDismissSafeError (#830)" pins both branches directly: mounted reports INLINE and does not call `onFailed`, unmounted calls `onFailed`, and the report goes through the LATEST callback rather than the one captured on first render. (2) `CollectionsScreen.test.tsx` → "reports a failed add on the screen when the dialog was dismissed before the request settled (#830)" drives the whole path — it parks the POST in flight, dismisses via Escape, then settles the request and asserts the message is on the screen and NOT inside a dialog. Executed: deleting `reportRef.current(message)` alone reddens it on `Unable to find an element with the text: Request failed with status 500`. That one clause is shared, so the same mutation reddens THREE tests (this one plus the two divert tests in (1)) — expect three reds, not one, when re-running it. (3) `CollectionsScreen.test.tsx` → "a late SUCCESS does not close the dialog the user reopened after dismissing (#830)" pins the OTHER half of the split: it dismisses mid-request, reopens the picker, then settles the request 204, and asserts the reopened dialog is still there — with an anti-vacuity check that the late response was actually processed (`onAdded` is `load`, so a second GET of the items endpoint must have happened), because otherwise "the dialog is still open" holds trivially. Executed: deleting the `if (mountedRef.current)` around `onClose()` reddens it alone. The POSITIVE direction is pinned separately, in the Song add test, because the negative one does not cover it: review measured that deleting the `onClose()` call ENTIRELY — so an ordinary successful add never closes the picker — left the whole suite green, so that test now asserts the dialog closes. (4) `CollectionsScreen.guards.test.tsx` counts is-mounted reads and moved from 2 to 1 when the catch''s guard migrated into the hook; its `...actual` module mock cannot see the hook''s internal `useIsMountedRef()`, which is why (1) exists. Executed: removing the surviving `finally` guard takes that count to 0 and reddens. (5) `CollectionsScreen.test.tsx` → "renders a failed add INSIDE the dialog while it is still open (#830)" pins which ARM the call site reaches, which (1) cannot: (1) proves the hook HAS an inline branch, not that this screen calls into it. It fails the POST with the dialog still up and asserts the message sits inside `[role="dialog"]` and appears exactly once in the tree. Executed 2026-09-04: swapping `reportFailure(...)` for `onAddFailed(...)` in the catch — the divert-while-open shape this record names as its own defect — reddens that test and NOTHING else (1 failed / 490 passed across the 42 `src/screens` files).'
---
**This record applies to ONE site, and the reason the rest were dropped is the useful part.**
An earlier draft claimed SUCCESS already outlives dismissal "at every one of these sites", reasoning
from `AddItemsDialog` — which does report success past dismissal, and says so in a comment — and
generalising to the four `web/src/media/addTo/` dialogs without reading them. Adversarial review
probed `AddToCollectionDialog` and MEASURED `onAdded` called 0 times after dismissal; the other
three carry a visibly identical gate (read, not probed). So those four gate BOTH halves of the
outcome behind their own `activeRef`, and dismiss-then-succeed is as silent there as
dismiss-then-fail.
Extending the mechanism to them was attempted and WITHDRAWN, which is why they are #877 and not this
record. Removing that gate reports success correctly but also un-gates `onClose()`, and the two mean
different things: `onAdded` is "tell the parent what happened", `onClose` is "close me" — addressed
to a surface that no longer exists. Measured against the real `AddToMenu`: a late success from a
DISMISSED dialog closed a dialog the user had since reopened. Read, not measured: the success
handlers on `SearchScreen` / `MediaBrowseScreen` also call `clearSelection()` unconditionally, so
the same late success would wipe a multi-select the user had rebuilt. Un-gating both is wrong,
gating both is wrong, and gating only `onClose` still
needs the parents to stop nulling the dialog themselves — three coupled edits across five files,
plus an unresolved question about whether `clearSelection()` should fire for a write the user walked
away from. That is a design decision, not a bug fix, so it goes to #877 with the measurements
attached rather than riding along here.
`AddToMenu` also has `onDone` and no failure counterpart at all, which is why a failed "Add to
collection" from a media card reports nowhere. Same issue.
**The outcome and the dismissal are different callbacks, and the exemplar proves it.** Review
measured the cost of conflating them twice: gating both makes a completed write silent, un-gating
both makes a late success close the dialog the user reopened to retry. `AddItemsDialog` now does
each correctly — `onAdded()` unguarded, because it reloads the parent's list and that must survive;
`onClose()` guarded, because after dismissal it is `setPickerOpen(false)` aimed at whatever the user
opened next. That split is only this simple where the parent has no competing closer: here
`onAdded` is `load`, which never touches `pickerOpen`. In the `media/addTo/` layer
`AddToMenu.handleAdded` closes the dialog itself, so the same one-line gate is NOT sufficient there
— measured — which is the concrete reason that half is #877 and not this record.
**Why the inline branch is kept rather than always reporting to the parent.** While the dialog is
up, inline is the better surface: it keeps the user's selections and the context they are looking
at. Diverting to a parent banner in that case would be its own defect — the message would surface
somewhere else while the dialog the user is staring at stays blank. Behind the dialog, in fact:
the panel is a `createPortal` with `aria-modal="true"`, so the screen banner is hidden from AT and
covered for everyone else. Pinned rather than argued — mechanics (5).
**Why not simply gate dismissal on the busy flag.** That was considered and rejected: gating
Escape/backdrop/close on `adding` traps the user behind an in-flight request with no cancel path,
which is exactly why `Dialog` does not do it by default. The request is also genuinely still
running — cancelling the UI would not cancel the write.
**#740 guarded this and did not fix it, correctly.** That issue added the is-mounted guard so the
`setError` could not fire on an unmounted tree. That is right and stays. Its scope was the async
guards, and it deliberately did not change the UX — which left the guard making the drop
*deliberate* rather than accidental. A deliberate drop is still a user who believes twelve items
were added when none were, and that is the half this record closes.
**Two limits of the mechanism, recorded rather than left to be rediscovered.** (1) `useIsMountedRef`
clears its flag in a PASSIVE effect cleanup, so there is a narrow window in which the DOM node is
detached but the flag still reads true — the message then renders inline into a dead tree instead of
diverting. `useLayoutEffect` would close it deterministically, but that hook is shared by every
async caller in the SPA (#578) and changing its timing is not something to do inside a bug fix;
tracked with the rest in #877. (2) A limit the SECOND surface will have, stated now so it is
not discovered when it is wired: `CollectionsScreen` reports into `role="alert"`, but the candidate
surface on `MediaBrowseScreen`/`SearchScreen` is `Toast`, which is `role="status"` (polite) and a
single last-writer-wins slot — so a failure diverted there would be announced less assertively and
could be overwritten by a later success. Nothing diverts to those screens today; they receive no
reporting callback.
**The hook has exactly one consumer, and that is worth saying.** `useDismissSafeError` is a shared
module with a single call site today. It earns the shape three ways: it is unit-tested directly in
`hooks.test.tsx`, in a way that would be awkward to keep if the hook were inlined into
`CollectionsScreen`; §3c prescribes it for the next site; and #877 is a queued second consumer. The
caveat:
#877 may conclude that A2's right answer is a shared reporting SURFACE rather than a per-site prop,
in which case this hook may never gain that second consumer and stays a one-off with a convention
section attached. That is an accepted risk, not an oversight.
**Scope, stated rather than implied.** A sweep of all 68 `Dialog`/`ConfirmDialog`/`SlideOver` call
sites (2026-08-29; 67 caller-owned plus `ConfirmDialog`'s own internal `<Dialog`) found three
shapes: (A1) the surface hard-unmounts and takes its own error state
with it — the shape fixed here, at `AddItemsDialog` ONLY — the four `web/src/media/addTo/` dialogs
are the same shape and are NOT fixed (#877, see above);
(A2) the error state lives in a parent that never unmounts, but the JSX rendering it is nested
inside the same `open`/`target` condition dismissal just cleared, so it has no DOM node to render
into — the majority pattern, mostly delete-confirm flows; (B) a genuinely surviving screen-level
banner, which `DecoTemplatesScreen` and `BlocksScreen` already have. Only ONE A1 site is fixed here.
A2 is
LEFT OPEN deliberately: it is a larger, mostly-delete-confirm population whose right answer may be
a shared reporting surface rather than a per-site prop, and folding it in would have made this PR a
rewrite of twenty screens. It is tracked in #877, which carries the sweep's verified-vs-inferred
split forward, rather than being recorded as done here.
+67
View File
@@ -404,6 +404,66 @@ double-invoke for the re-arm) **and** the *integration* (mock the hook module an
component actually read `current` — and saw `false` — when the late response landed). Verify each by
removing the mechanism and confirming the test fails.
## 3c. A write failure must outlive the dismissible surface that started it (#830)
`Dialog`, `SlideOver` and `ConfirmDialog` are all dismissible **mid-request** through three paths
that never consult a busy flag: Escape and a backdrop/scrim click (both via `useOverlayBehavior`),
and the header close button. Disabling the footer Cancel button — which nearly every dialog here
does — looks like it closes that hole and does not.
So an error rendered from the dismissible component's own state has nowhere to go once the user
dismisses it. The request still runs, still fails, and the screen reloads unchanged: the user reads
that as success. Guarding the `setError` with an is-mounted check (§3b) is necessary but **not
sufficient** — it makes the drop deliberate rather than accidental, which is still a drop.
**Use `useDismissSafeError` (`web/src/hooks.ts`).** It renders the message inline while the surface
is mounted — the better surface, since it keeps the user's context — and hands it to a
caller-supplied `onFailed` once the surface is gone:
```tsx
const { inlineError, reportFailure, setInlineError } = useDismissSafeError(onFailed);
// ...
} catch (err) {
reportFailure(messageFromXError(err, 'Unable to …'));
}
```
The surviving surface belongs to the **parent**, so `onFailed` is a prop contract, not a rendering
decision. `CollectionsScreen` reports into its screen-level `role="alert"` banner — the only wired
site today. The other candidate surface is the `notice` + `Toast` pair that `MediaBrowseScreen` and
`SearchScreen` already use for success. There is no global toast host in this SPA; do not add one
for a single screen.
The two are **not equivalent**, so pick deliberately if you wire the second: `Toast` is
`role="status"` (polite, announced less assertively than `role="alert"`) and each screen has a
single `notice` slot, so a later success Toast would overwrite a pending failure one. Nothing
diverts to those screens today — they receive no reporting callback.
**Both halves of the outcome must reach the surviving caller**, and they are not the same kind of
callback. Report the OUTCOME (`onAdded` / `onDone` / `onFailed`) unguarded — gating a success
callback on an is-mounted check makes a completed write silent, exactly like the failure case. But
gate the DISMISS request (`onClose`): it means "close me", and after dismissal "me" is whatever the
user opened next. The four `media/addTo/` dialogs currently gate BOTH (measured on
`AddToCollectionDialog`: `onAdded` called 0 times after dismissal) and are tracked in #877
separating the two there needs the parent screens to stop nulling the dialog themselves, so it is a
design change rather than a one-line fix. When you add a success callback to a dialog, add its
failure counterpart, and keep the close decision distinct from the report.
Make the reporting prop **required** where the host screen has a surface for it —
`AddItemsDialog.onAddFailed` is, so the failure cannot be dropped by forgetting to wire it. Make it
optional only when some host genuinely has nowhere to report (the `media/addTo/` layer's eventual
one will be, because `MediaDetailScreen` wires no outcome callbacks at all); there, an omitted
`onFailed` drops the failure exactly as today — a known gap, not coverage (#877). Rationale and the
full call-site sweep: `docs/decisions/records/spa/dismissible-write-failure-reporting.md`.
**Pin the arm the CALL SITE reaches, not only the hook's.** A unit test of `useDismissSafeError`
proves the inline branch exists; it cannot see a call site that reports through `onFailed` directly
and so pushes the message onto the parent banner while the surface is still up — behind a
`createPortal` panel with `aria-modal="true"`, i.e. covered for sighted users and hidden from AT.
Add an integration assertion for each arm: fail the write with the surface still open and assert the
message is inside `[role="dialog"]`, and fail it again across a dismissal and assert the message is
on the screen and not in a dialog.
## 4. API client modules
One file per domain in `web/src/api/`, e.g. `logs.ts`, `blocks.ts`, `playouts.ts`. Pattern (see
@@ -603,6 +663,13 @@ page's action row) and the `AddToCollectionDialog` / `AddToPlaylistDialog` / `Ad
on grid screens is an explicit "Select" toggle (see `docs/decisions.md` 2026-07-10 for the rationale
and the accepted deviations from Blazor).
Each dialog in this layer is unmounted the moment it is dismissed, so an outcome the screen does not
receive is an outcome the user never sees. As of 2026-08-29 the layer has **no failure channel at
all** — `AddToMenu` exposes `onDone` and no counterpart — and its dialogs gate the success callback
on their own unmount guard, so a write that settles after dismissal reports nothing in either
direction. Adding one is #877; see §3c for the mechanism and for why the close decision has to stay
separate from the report.
## 5d. Client-local preferences: `localStorage`, namespaced `ctv-*` keys
Per-browser UI preferences (theme, an auth token, a screen's remembered page size) live in
+69 -2
View File
@@ -1,7 +1,7 @@
import { cleanup, render } from '@testing-library/react';
import { act, cleanup, render } from '@testing-library/react';
import { StrictMode, useEffect } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import { useIsMountedRef } from './hooks';
import { useDismissSafeError, useIsMountedRef } from './hooks';
afterEach(cleanup);
@@ -56,3 +56,70 @@ describe('useIsMountedRef (#578)', () => {
expect((captured as unknown as { current: boolean }).current).toBe(true);
});
});
describe('useDismissSafeError (#830)', () => {
interface Handle {
inlineError: string | null;
reportFailure: (message: string) => void;
}
function renderProbe(report: (message: string) => void) {
const handle: Handle = { inlineError: null, reportFailure: () => {} };
function Probe() {
const hook = useDismissSafeError(report);
handle.inlineError = hook.inlineError;
handle.reportFailure = hook.reportFailure;
return <span>{hook.inlineError}</span>;
}
return { handle, ...render(<Probe />) };
}
it('renders the failure INLINE while the surface is still mounted', async () => {
const reported: string[] = [];
const { handle, findByText } = renderProbe((message) => reported.push(message));
act(() => handle.reportFailure('nope'));
// Inline is the better surface while the dialog is up: it keeps the user's context. Diverting
// to the parent here would ALSO be a defect — the message would appear somewhere else while the
// dialog the user is looking at stays blank.
expect(await findByText('nope')).toBeInTheDocument();
expect(reported).toEqual([]);
});
it('diverts the failure to the surviving surface once the surface is gone', () => {
const reported: string[] = [];
const { handle, unmount } = renderProbe((message) => reported.push(message));
unmount();
handle.reportFailure('gone');
// This is the #830 defect itself: before the fix the message was dropped here and the user was
// left believing the write succeeded. Delete `reportRef.current(message)` and this goes red.
expect(reported).toEqual(['gone']);
});
it('reports through the LATEST callback, not the one captured on first render', () => {
const first: string[] = [];
const second: string[] = [];
function Probe({ sink }: { sink: (message: string) => void }) {
handle = useDismissSafeError(sink);
return null;
}
let handle: ReturnType<typeof useDismissSafeError> = null as never;
const { rerender, unmount } = render(<Probe sink={(m) => first.push(m)} />);
rerender(<Probe sink={(m) => second.push(m)} />);
unmount();
handle.reportFailure('latest');
// Parents hand down a fresh closure every render (the same reason `useOverlayBehavior` keeps a
// latest-ref). Capturing the first one would report into a stale closure.
expect(first).toEqual([]);
expect(second).toEqual(['latest']);
});
});
+56 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
// Shared is-mounted guard for async callbacks (#578). A monotonic `seqRef` drops a STALE response
// (an older request resolving after a newer one) but says nothing about whether the component is
@@ -19,3 +19,58 @@ export function useIsMountedRef(): { readonly current: boolean } {
return ref;
}
// A write failure must land on a surface that OUTLIVES the component that started it (#830).
//
// `Dialog` and `SlideOver` are dismissible mid-request through three paths that do not consult a
// busy flag -- Escape and the backdrop/scrim click via `useOverlayBehavior`, plus the header close
// button (`components/overlay.tsx`). Callers commonly disable only the footer Cancel button, which
// looks like it closes the hole and does not. Dismissal unmounts the surface, so an error rendered
// from ITS OWN state has nowhere to go: the request still runs, still fails, and the user is told
// nothing while the list reloads unchanged.
//
// This hook carries the FAILURE half. `AddItemsDialog`, its one consumer, already reported SUCCESS
// past dismissal -- `onAdded`/`onClose` belong to the parent, and it says so at the call site -- so
// the fix routes failure through that same parent channel rather than adding a notification system.
// The four `media/addTo/` dialogs gate BOTH halves behind their own `activeRef` and are NOT fixed;
// separating the two there needs the parent screens to stop closing their own dialogs, so it is a
// design change tracked in #877, not something this hook already solved.
//
// While mounted the error stays INLINE, which is the better surface: it keeps the user's selections
// and their context. `reportFailure` only diverts once the surface is gone.
//
// `reportAfterDismiss` is read through a latest-ref for the same reason `useOverlayBehavior` does
// it: parents hand down a fresh closure every render, and the ref must still be callable AFTER this
// component unmounts, which is precisely when it is used. Note the reach is ONE level: the
// reporting callback (`AddItemsDialog.onAddFailed`) targets whatever component owns the surviving
// surface, so if THAT unmounts too -- navigating away,
// or `CollectionsScreen`'s Back out of a collection while an add is in flight -- the setState is a
// no-op on React 18+ and the message is still lost. Hoisting the banner further up is what would
// shrink that, not a change here.
export function useDismissSafeError(reportAfterDismiss: (message: string) => void): {
readonly inlineError: string | null;
readonly setInlineError: (message: string | null) => void;
readonly reportFailure: (message: string) => void;
} {
const mountedRef = useIsMountedRef();
const [inlineError, setInlineError] = useState<string | null>(null);
const reportRef = useRef(reportAfterDismiss);
useEffect(() => {
reportRef.current = reportAfterDismiss;
});
const reportFailure = useCallback(
(message: string) => {
if (mountedRef.current) {
setInlineError(message);
return;
}
reportRef.current(message);
},
[mountedRef]
);
return { inlineError, reportFailure, setInlineError };
}
@@ -300,11 +300,20 @@ describe('AddItemsDialog async guards (#740)', () => {
mountedReads.length = 0;
parked[0].fail();
// `submit` has exactly TWO post-await writes to this component's own state — `setError` in the
// catch and `setAdding` in the finally — and the invariant is that BOTH consult the guard.
// Counting them is what keeps the two from masking each other: asserting only that SOME read
// saw `false` stays green with either one removed, because the survivor still reads the ref.
await waitFor(() => expect(mountedReads).toHaveLength(2));
// `submit` has exactly ONE post-await write left to state THIS component owns directly —
// `setAdding` in the finally. That is a census of OWN-STATE writes, not of `mountedRef` reads:
// the SUCCESS path has its own guarded `onClose()` (#830), which this failure-path test never
// reaches, so do not derive the guard population from this number. It was two until #830 moved the catch's report into
// `useDismissSafeError`, which keeps its own is-mounted guard and decides between the inline
// banner and the parent's surviving surface; that guard is invisible here because the mock
// above spreads `...actual`, so the hook's INTERNAL `useIsMountedRef()` call resolves to the
// real one rather than this proxy. Both halves of it are pinned directly by
// `hooks.test.tsx` → "useDismissSafeError (#830)".
//
// The exact count still does the anti-masking work it was added for: asserting only that SOME
// read saw `false` would stay green with the remaining guard deleted, because there would be no
// read left to contradict it. Pinning the number means removing this one gives 0 and reddens.
await waitFor(() => expect(mountedReads).toHaveLength(1));
expect(mountedReads.every((read) => read === false)).toBe(true);
});
});
+166 -1
View File
@@ -49,6 +49,9 @@ interface MockOptions {
manual?: unknown[];
smart?: unknown[];
onRequest?: (url: string, method: string, body: unknown) => Response | null;
// Returns a promise the TEST settles, so a request can be left in flight while the surface that
// started it is dismissed (#830). `onRequest` cannot express that: it answers synchronously.
onRequestDeferred?: (url: string, method: string, body: unknown) => Promise<Response> | null;
}
function mockApi(options: MockOptions = {}) {
@@ -60,6 +63,14 @@ function mockApi(options: MockOptions = {}) {
const method = (init?.method ?? 'GET').toUpperCase();
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
if (options.onRequestDeferred) {
const deferred = options.onRequestDeferred(url, method, body);
if (deferred) {
return deferred;
}
}
if (options.onRequest) {
const override = options.onRequest(url, method, body);
@@ -360,8 +371,9 @@ describe('CollectionsScreen', () => {
RemoteStream: { id: 10, mediaType: 'RemoteStream', title: 'Live Feed' }
};
function mockAddItemsApi() {
function mockAddItemsApi(onRequestDeferred?: MockOptions['onRequestDeferred']) {
return mockApi({
onRequestDeferred,
onRequest: (url) => {
if (url.startsWith('/api/v1/library/browse')) {
const params = new URL(url, 'http://localhost').searchParams;
@@ -452,6 +464,153 @@ describe('CollectionsScreen', () => {
).toBeInTheDocument();
});
// #830, the arm the dismissal test below cannot see. `useDismissSafeError` has two of them and the
// call site picks one by calling `reportFailure` rather than `onAddFailed` directly. While the
// dialog is STILL UP the message belongs inline, beside the selections the user built: the
// screen's banner sits behind a `createPortal` panel carrying `aria-modal="true"`
// (`components/overlay.tsx`), so diverting there would leave the surface the user is staring at
// blank and hide the message from assistive tech. Measured: wiring `onAddFailed` at this call
// site instead of `reportFailure` leaves every other test in `src/screens` green.
it('renders a failed add INSIDE the dialog while it is still open (#830)', async () => {
mockAddItemsApi((url, method) =>
url === '/api/v1/collections/1/items' && method === 'POST'
? Promise.resolve(new Response(null, { status: 500 }))
: null
);
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
expect(await within(dialog).findByText('Zathura')).toBeInTheDocument();
fireEvent.click(within(dialog).getByText('Zathura'));
fireEvent.click(within(dialog).getByRole('button', { name: /Add 1 item/ }));
// The message lands in the dialog's own role="alert", not the screen's.
const inline = await within(dialog).findByText('Request failed with status 500');
expect(inline.closest('[role="dialog"]')).toBe(dialog);
// ...and NOWHERE else. One copy in the whole tree proves the parent banner did not also receive
// it, which is what a `reportFailure` -> `onAddFailed` swap at the call site would produce.
expect(screen.getAllByText('Request failed with status 500')).toHaveLength(1);
// Still up, with the selection intact — the reason inline is the right surface here at all.
expect(screen.getByRole('dialog')).toBe(dialog);
expect(within(dialog).getByRole('button', { name: /Add 1 item/ })).toBeInTheDocument();
});
// #830. The dialog is dismissible mid-request through three paths that never consult `adding`
// (Escape, backdrop click, header close button — `components/overlay.tsx`); only the footer Cancel
// is disabled, which looks like it closes the hole. Dismissal genuinely unmounts the instance,
// because the caller keys it on `add-${pickerOpen}`. #740 guarded the in-dialog `setError` so the
// drop became deliberate; the user-visible half is that a deliberate drop still reads as success.
//
// The request is left IN FLIGHT across the dismissal on purpose — settling it before the Escape
// would take the still-mounted path and prove nothing about the bug.
it('reports a failed add on the screen when the dialog was dismissed before the request settled (#830)', async () => {
let failAdd: (() => void) | undefined;
const addInFlight = new Promise<Response>((resolve) => {
failAdd = () => resolve(new Response(null, { status: 500 }));
});
const fetchMock = mockAddItemsApi((url, method) =>
url === '/api/v1/collections/1/items' && method === 'POST' ? addInFlight : null
);
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
expect(await within(dialog).findByText('Zathura')).toBeInTheDocument();
fireEvent.click(within(dialog).getByText('Zathura'));
fireEvent.click(within(dialog).getByRole('button', { name: /Add 1 item/ }));
// The POST is out, and nothing has answered it yet.
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([u, init]) => u === '/api/v1/collections/1/items' && (init?.method ?? '').toUpperCase() === 'POST'
)
).toBe(true);
});
// Dismiss via Escape — the path the disabled Cancel button does not cover.
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => {
expect(screen.queryByRole('dialog')).toBeNull();
});
failAdd?.();
// The failure lands on the SCREEN's own role="alert" banner, which outlives the dialog. Asserting
// the message rather than merely "an alert exists" keeps this from passing on an unrelated banner.
const alert = await screen.findByText('Request failed with status 500');
expect(alert).toBeInTheDocument();
// ...and it is genuinely on the screen, not inside a dialog that somehow survived.
expect(screen.queryByRole('dialog')).toBeNull();
expect(alert.closest('[role="dialog"]')).toBeNull();
});
// #830, the other half of the same mechanism. `onAdded` and `onClose` are not the same kind of
// callback: the first REPORTS an outcome and must survive dismissal, the second REQUESTS A
// DISMISSAL and must not — after dismissal it closes whatever the user opened next. Un-gating both
// was measured wrong in review; this pins the split at the one site that ships it.
it('a late SUCCESS does not close the dialog the user reopened after dismissing (#830)', async () => {
let succeedAdd: (() => void) | undefined;
const addInFlight = new Promise<Response>((resolve) => {
succeedAdd = () => resolve(new Response(null, { status: 204 }));
});
const fetchMock = mockAddItemsApi((url, method) =>
url === '/api/v1/collections/1/items' && method === 'POST' ? addInFlight : null
);
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'za' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Search' }));
expect(await within(dialog).findByText('Zathura')).toBeInTheDocument();
fireEvent.click(within(dialog).getByText('Zathura'));
fireEvent.click(within(dialog).getByRole('button', { name: /Add 1 item/ }));
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([u, init]) => u === '/api/v1/collections/1/items' && (init?.method ?? '').toUpperCase() === 'POST'
)
).toBe(true);
});
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
// The user reopens the picker to retry the add they believe failed.
fireEvent.click(screen.getByRole('button', { name: 'Add items' }));
const reopened = await screen.findByRole('dialog');
// ...and only now does the first request come back, successfully.
succeedAdd?.();
// ANTI-VACUITY: prove the late response was actually processed, so the assertion below is not
// just "nothing has happened yet". `onAdded` is `load`, which refetches the collection's items.
await waitFor(() => {
const itemReads = fetchMock.mock.calls.filter(
([u, init]) => /\/api\/v1\/collections\/1\/items/.test(u.toString()) && (init?.method ?? 'GET').toUpperCase() === 'GET'
);
expect(itemReads.length).toBeGreaterThan(1);
});
// Remove the `if (mountedRef.current)` around `onClose()` and this dialog vanishes under the user.
expect(reopened).toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeNull();
});
it('selecting a newly-addable kind filter (Song) surfaces it and buckets it correctly on add', async () => {
const fetchMock = mockAddItemsApi();
@@ -479,6 +638,12 @@ describe('CollectionsScreen', () => {
expect(addCall).toBeDefined();
expect(JSON.parse(String(addCall?.[1]?.body)).songIds).toEqual([7]);
});
// #830: the POSITIVE direction of the report/dismiss split. `onClose()` is guarded on
// `mountedRef` so a LATE success cannot close a dialog the user reopened; this pins that the
// guard did not make the ordinary close dead. Measured: without this line, deleting the
// `onClose()` call entirely left the whole suite green.
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it(
+39 -22
View File
@@ -48,7 +48,7 @@ import {
type MediaCollection,
type SmartCollection
} from '../api';
import { useIsMountedRef } from '../hooks';
import { useDismissSafeError, useIsMountedRef } from '../hooks';
import { TYPE_LABEL } from '../media/mediaKinds';
import { SmartCollectionDialog } from '../builder/SmartCollectionDialog';
@@ -224,11 +224,13 @@ function NameDialog({
function AddItemsDialog({
collection,
onAdded,
onAddFailed,
onClose,
open
}: {
collection: MediaCollection;
onAdded: () => void;
onAddFailed: (message: string) => void;
onClose: () => void;
open: boolean;
}) {
@@ -242,7 +244,9 @@ function AddItemsDialog({
const [submittedQuery, setSubmittedQuery] = useState('');
const [searching, setSearching] = useState(false);
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(() => new Map());
const [error, setError] = useState<string | null>(null);
// #830: `inlineError` renders here while the dialog is up; `reportFailure` diverts to
// `onAddFailed` once it has been dismissed, so a failed add cannot vanish with the surface.
const { inlineError, reportFailure, setInlineError } = useDismissSafeError(onAddFailed);
const [adding, setAdding] = useState(false);
const [kindFilter, setKindFilter] = useState<MediaKindFilter>('all');
const seqRef = useRef(0);
@@ -271,7 +275,7 @@ function AddItemsDialog({
const isCurrent = () => mountedRef.current && seqRef.current === seq;
setSearching(true);
setError(null);
setInlineError(null);
try {
const kinds = filter === 'all' ? DEFAULT_SEARCH_KINDS : [filter];
@@ -298,7 +302,7 @@ function AddItemsDialog({
if (!isCurrent()) {
return;
}
setError(messageFromCollectionError(searchError, 'Unable to search library'));
setInlineError(messageFromCollectionError(searchError, 'Unable to search library'));
// A failed search must not leave a stale results/totalMatches pair rendering a confident
// "Showing N of M matches" hint beside the error banner (#685 review).
setResults([]);
@@ -342,25 +346,35 @@ function AddItemsDialog({
}
setAdding(true);
setError(null);
setInlineError(null);
try {
await addItemsToCollection(collection.id, toAddItemsRequest([...selected.values()]));
// NOT guarded: both belong to the still-mounted PARENT. `onAdded` reloads the collection's
// items, and dropping it because this dialog went away would leave the list stale after a
// write that succeeded.
// These two are NOT the same kind of callback, and only one of them survives dismissal.
//
// `onAdded` REPORTS what happened: it reloads the parent's item list, and dropping it because
// this dialog went away would leave that list stale after a write that succeeded. Unguarded.
//
// `onClose` REQUESTS A DISMISSAL, and after dismissal the thing it closes is no longer this
// instance -- `onClose` is `() => setPickerOpen(false)` on a parent that has since reopened
// the picker. Firing it from a dead instance closes the dialog the user reopened to retry,
// discarding the selection they just rebuilt (measured). Guarded. See `spa-conventions.md`
// §3c: report the outcome unguarded, gate the dismiss request.
onAdded();
onClose();
} catch (addError) {
// Guarded: these two are this component's own state. The dialog is unmount-reachable while a
// request is in flight even though the Add button is disabled — Escape, a backdrop click and
// the header close button all reach `onClose` without consulting `adding`
// (`components/overlay.tsx`), and the caller remounts on `key={`add-${pickerOpen}`}`. This is
// the is-mounted guard's own trigger, unmount-while-in-flight, and it needs no `seq`: the
// disabled Add button means there is never a second submission to be superseded by.
if (mountedRef.current) {
setError(messageFromCollectionError(addError, 'Unable to add items'));
onClose();
}
} catch (addError) {
// The dialog is unmount-reachable while a request is in flight even though the Add button is
// disabled — Escape, a backdrop click and the header close button all reach `onClose` without
// consulting `adding` (`components/overlay.tsx`), and the caller remounts on
// `key={`add-${pickerOpen}`}`. #740 guarded this `setError` so the drop was deliberate rather
// than accidental; #830 is the missing half — a deliberate drop still leaves the user believing
// the add succeeded. `reportFailure` renders inline while the dialog is up and hands the
// message to the parent screen once it is gone. No `seq` is needed: the disabled Add button
// means there is never a second submission to supersede this one.
reportFailure(messageFromCollectionError(addError, 'Unable to add items'));
} finally {
if (mountedRef.current) {
setAdding(false);
@@ -428,9 +442,9 @@ function AddItemsDialog({
{/* Conditionally mounted, unlike the aria-live hint below deliberately, not by oversight:
`role="alert"` is the one live-region role screen readers reliably announce on INSERTION,
so mounting it together with its content is right here and would be wrong there (#685). */}
{error && (
{inlineError && (
<span className="ctv-field-error" role="alert">
{error}
{inlineError}
</span>
)}
{submittedQuery !== '' && (
@@ -448,7 +462,7 @@ function AddItemsDialog({
{totalMatches > results.length ? `Showing ${results.length} of ${totalMatches} matches — narrow your search.` : ''}
</p>
<div className="ctv-collections-picker-results">
{results.length === 0 && submittedQuery === '' && !error ? (
{results.length === 0 && submittedQuery === '' && !inlineError ? (
// Keyed to submittedQuery, not the live input: without it, backspacing the query back
// below the min-query length after a successful search wiped the rendered rows AND their
// checkmarks while `selected` (and the Add button's count) still held them (#685) — and,
@@ -457,8 +471,8 @@ function AddItemsDialog({
<div className="ctv-collections-picker-empty">
Type at least {LIBRARY_PICKER_MIN_QUERY} characters to search.
</div>
) : results.length === 0 && !searching && !error ? (
// Also suppressed on `error`: "No results" asserts a search that COMPLETED and found
) : results.length === 0 && !searching && !inlineError ? (
// Also suppressed on `inlineError`: "No results" asserts a search that COMPLETED and found
// nothing, which is false when the request failed. The role="alert" banner above is the
// whole message in that state (#685).
<div className="ctv-collections-picker-empty">No results try a search above.</div>
@@ -821,6 +835,9 @@ function ManualItemsView({
collection={collection}
key={`add-${pickerOpen}`}
onAdded={load}
// #830: the surviving surface. This screen's own `role="alert"` banner outlives the dialog,
// so a failure that lands after dismissal is still reported where the user actually is.
onAddFailed={setError}
onClose={() => setPickerOpen(false)}
open={pickerOpen}
/>