fix(plex): resolve deep-review findings on the Discover/Collections batch

Round of USER-triggered deep-review fixes (11 CONFIRMED + 1 PLAUSIBLE, all
verified) on the 22-commit branch:

- collections: split the mutate-then-resync error surface. A resync READ that
  fails after a successful Plex mutation no longer reports a false 422 "Plex
  write failed" + rollback; it returns 502 "sync pending" (the next full sync
  reconciles the mirror). New _write_or_pending_error helper; delete is exempt
  (no Plex read). Covered by new happy-path / 422-rollback / 502 tests.
- New-collection dup-name guard is now advisory only: existingNames is the
  search-scoped, all-library union, but the backend enforces uniqueness PER
  library — so it no longer blocks a valid same-name collection in another
  library, and the backend 409 is localized to nameTaken.
- member-remove now confirms before the write-through (matches delete-collection).
- add-title / new-collection library search render an error state on request
  failure instead of a misleading "No matches".
- history: seed navCounter past the current entry's persisted sfScroll on load
  so post-reload ids can't collide with back-stack entries and mis-restore scroll.
- PageScroller: LRU-cap the navId-keyed savedScroll map (was unbounded).
- share isIntrinsicReadOnly between the browse tile and the info page so the
  lock/edit affordance can't drift.
This commit is contained in:
2026-08-23 06:57:33 +02:00
parent b71b33654a
commit 6af238c2ea
10 changed files with 227 additions and 21 deletions
+13 -1
View File
@@ -8,6 +8,18 @@ import { ensureNavId, peekNavId, subscribeNav } from "../lib/history";
// top WITHIN the same entry. Module-level so a view's position survives its scroller unmounting
// (opening a channel page swaps in a different shell/scroller) and is restored when it returns.
const savedScroll = new Map<string, number>();
// The key embeds an ever-increasing history-entry id, so without a bound this map would grow one
// entry per scrolled navigation for the life of the session. Cap it with a small LRU (Map preserves
// insertion order): re-insert on write to mark most-recently-used, evict the oldest past the cap.
const SCROLL_MEMORY_CAP = 200;
function rememberScroll(key: string, top: number): void {
savedScroll.delete(key);
savedScroll.set(key, top);
if (savedScroll.size > SCROLL_MEMORY_CAP) {
const oldest = savedScroll.keys().next().value;
if (oldest !== undefined) savedScroll.delete(oldest);
}
}
// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame
// or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise
@@ -120,7 +132,7 @@ export default function PageScroller({
if (!el) return;
const onScroll = () => {
if (scrollKeyRef.current == null) return;
savedScroll.set(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop);
rememberScroll(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop);
};
el.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("popstate", resolve);