refactor(frontend): the long tail — and eslint-suppressions.json is gone (E27 S2)
Closes the burn-down. Hook suppressions 113 -> 34, `exhaustive-deps` 45 -> 2 (the target was <10), and every one of the 34 survivors is an inline directive NAMING the invariant it protects. The suppressions file is deleted, because a bulk file hides the next violation instead of answering it — and it cannot hold a warning-level rule at all. The sprint plan's three verdicts missed the answer that turned out to be the most common one: adjust the state DURING RENDER. React re-runs the component and discards that pass rather than committing it, so the stale frame an effect-based reset paints does not exist. Applied to the Plex player's up-next card, the playlist view reset, the feed's optimistic overrides, the channel banner's retry count, the download center's tab guard, the Plex search highlight, the comfort-pin latch, Welcome's per-view outcome and the card pager's page clamp. It is legal only for a component's own state — PlexBrowse's language commit resets PlexProvider's filters, so that one stays an effect and says why. Mirrors of a prop or query became derived values or `draft ?? server`: - `downloadRow` had ui/Avatar's defect exactly — a boolean "poster failed" that an effect reset, in rows recycled down a virtualized list, so one job's missing poster painted over the next one's for a frame. Keyed by URL now. - Scheduler's two number editors held a mirror an effect re-synced, on a page that POLLS — the shape a poll can overwrite mid-edit. A draft while editing, the server's value otherwise; six tests where there were none, the one behavioural change (the save button stops being dirty at commit rather than at the echo) mutation-checked. - SetupWizard's "no token" and WatchPage's "gone" are facts about the URL, so they are seeded and derived rather than pushed into state by an effect that had to commit first. Elsewhere: PlexInfo's rating chip was a component declared during render — a new component type every render, so React remounted the subtree and dropped the focus on its filter button. Hoisted. Feed's undo action and PlexPlayer's session reclaim both re-entered their own useCallback, which the checker reads as a forward reference; both go through the ref idiom this file already uses. BootGate's dep list was load-bearing for correctness — a re-run meant `location.reload()` — so the "already adopted THIS identity" comparison now enforces it in code, with a test that fails without it (the existing reload tests all install their spy too late to catch it). One correction to the batch before this: keying ChatThread's and Messages' decrypt passes on `q.data` instead of `q.dataUpdatedAt` was wrong. react-query's structural sharing makes it safe in production, but the component must not melt down when something hands back a fresh object per render — and Messages.keyboard.dom.test does exactly that, so the effect span the suite. Both are back on the fetch as the trigger with the payload behind a ref, which is honest AND loop-proof; the replaced comment had warned about precisely this. CLAUDE.md gains four canonical-patterns rows for the above, and its "React 18" line is now true.
This commit is contained in:
@@ -220,17 +220,21 @@ export default function Feed({
|
||||
if (qEmpty && parseSort(filters.sort).key === "relevance") {
|
||||
setFilters({ ...filters, sort: buildSort("date", "desc") });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qEmpty]);
|
||||
// Naming `filters` is safe precisely because the write below falsifies the condition above: the
|
||||
// sort it sets is "date", so the one re-run it causes does nothing. The old `[qEmpty]` was
|
||||
// guarding against a loop that the guard already prevents.
|
||||
}, [qEmpty, filters, setFilters]);
|
||||
|
||||
// Drop optimistic status overrides when filters change OR fresh server data
|
||||
// arrives — after a refetch the server is authoritative, so a stale override
|
||||
// (e.g. a video reverted to "new" from the notification center) won't keep it
|
||||
// filtered out of the current view.
|
||||
useEffect(() => {
|
||||
const [overridesFor, setOverridesFor] = useState(filters);
|
||||
if (overridesFor !== filters) {
|
||||
setOverridesFor(filters);
|
||||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
}, [filters]);
|
||||
}
|
||||
// ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
|
||||
// rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
|
||||
// Only a real refetch (page count doesn't grow) is authoritative.
|
||||
@@ -284,7 +288,7 @@ export default function Feed({
|
||||
message: v?.title
|
||||
? i18n.t("feed.hiddenNamed", { title: v.title })
|
||||
: i18n.t("feed.hidden"),
|
||||
action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") },
|
||||
action: { label: i18n.t("feed.undo"), onClick: () => onStateRef.current(id, "new") },
|
||||
meta: {
|
||||
kind: "video-hidden",
|
||||
videoId: id,
|
||||
@@ -299,7 +303,10 @@ export default function Feed({
|
||||
message: v?.title
|
||||
? i18n.t("feed.markedWatchedNamed", { title: v.title })
|
||||
: i18n.t("feed.markedWatched"),
|
||||
action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") },
|
||||
action: {
|
||||
label: i18n.t("feed.unwatch"),
|
||||
onClick: () => onStateRef.current(id, "new"),
|
||||
},
|
||||
meta: {
|
||||
kind: "video-watched",
|
||||
videoId: id,
|
||||
@@ -328,6 +335,11 @@ export default function Feed({
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
// The toast's Undo action re-enters onState, which is a forward reference to the binding still
|
||||
// being defined — the same shape PlexPlayer's session reclaim has, and answered the same way.
|
||||
const onStateRef = useRef(onState);
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
onStateRef.current = onState;
|
||||
|
||||
const onResetState = useCallback(
|
||||
(id: string) => {
|
||||
|
||||
Reference in New Issue
Block a user