fix(frontend): four state defects the deep-review found in the E27 S2 sweep

Every one of them is the same shape: a value derived correctly, and a WRITE to
the thing it derives from left unattended. Deriving hides a stale value; it does
not consume it.

PlayerModal — the watch toggle inherited the previous video's auto-watch. Moving
the queue-advance sync into render was right for `currentVideoId`, but the same
block also synced `status`, and that one had an ordering constraint of its own:
useYtProgress's cleanup for the OUTGOING item can still call setWatched(true)
near the end of a clip, and cleanups run after the commit. As an effect the sync
landed last and corrected it; from render it lands first and the stray write
wins. Press Next in the final ten seconds and the NEXT video showed as watched —
one click on the toggle then sent onState(next, "new") for an item already new.
Fixed by keying the mirror to the id it describes, so a write for another item is
ignored rather than displayed, and no ordering has to be argued about at all.

ChannelsProvider — a focus-channel intent was masked by the page rather than
consumed, and nothing ever cleared it. The manager is a page chunk: it unmounts
on every navigation, and its seed effect runs again on each mount, so returning
to it re-applied a channel name from earlier in the session over whatever the
user had typed since. The consumed marker now lives in the provider, keyed on the
token — which also closes the case the old clear-on-leave effect never handled,
opening a channel page (that does not change `page` at all).

TagFilterChips + ChannelTags x2 — `open = openRaw && hidden > 0` hides the
overflow popover when the lane stops overflowing, but floating-ui only reports a
close for a real event on a mounted surface, so the raw flag stayed true and the
menu came back by itself on the next re-fit — a window resize or the text-size
slider is enough, with nobody near it. The flag is now reset in render against
the same condition that hides it.

Avatar + downloadRow — remembering a failed image BY URL fixed the recycled-row
frame but never forgot it, so a transient 429 pinned a channel to its initial for
the life of the instance, never re-requesting. The memory is now dropped once
`src` moves off it, which keeps both properties.

And playerModalHooks' last exhaustive-deps suppression claimed "nothing in the
body reads a render value" while reading five. The invariant that is actually
load-bearing is that each of them is a per-item snapshot — written down instead.

All four fixes are mutation-checked: each new test was seen red, against the
fixed line only, naming the real defect.
This commit is contained in:
2026-09-21 23:32:22 +02:00
parent c5e022f056
commit 332d24a646
13 changed files with 330 additions and 22 deletions
+8 -2
View File
@@ -121,8 +121,12 @@ function SuggestedChipsLane({
// Open only while there is a "+N" trigger to anchor it — derived, not closed by an effect, so the
// frame in which the last collapsed chip fits again cannot also hold a menu pointing at a
// reference that has gone. Same shape as TagFilterChips and the second row below.
// reference that has gone. Same shape as TagFilterChips and the second row below, INCLUDING the
// render-phase reset: deriving hides the card but never clears the flag (a hover card whose pill
// unmounts under the cursor gets no mouseleave), so without this it springs back the next time
// the lane overflows. Convergent — the write falsifies its own condition.
const [overflowOpenRaw, setOverflowOpen] = useState(false);
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
const overflowOpen = overflowOpenRaw && hidden > 0;
const overflowPop = useAnchoredPopover({
open: overflowOpen,
@@ -355,8 +359,10 @@ export function ChannelTagsControl({
});
// Derived for the same reason as the suggestion row above: if tags change so nothing is collapsed
// anymore, the "+N" trigger unmounts, and the popover must already be shut in that same render.
// anymore, the "+N" trigger unmounts, and the popover must already be shut in that same render —
// and the flag reset with it, or the card returns unasked on the next overflow.
const [overflowOpenRaw, setOverflowOpen] = useState(false);
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
const overflowOpen = overflowOpenRaw && hidden > 0;
const overflowPop = useAnchoredPopover({
open: overflowOpen,
+10 -2
View File
@@ -86,6 +86,7 @@ export default function Channels() {
setLayout,
setSort,
focusChannel: onFocusChannel,
consumeFocus,
} = useChannelsActions();
const { openChannel: onViewChannel, setPage } = useNavigationActions();
const feedFilters = useFeedFilters();
@@ -284,12 +285,19 @@ export default function Channels() {
// The TOKEN is the intent (it advances even when the same channel is re-focused); the name is the
// payload, so it rides a ref. Naming it would re-seed the box on any render that changed it,
// overwriting whatever the user had since typed.
//
// `consumeFocus` is what stops this page's OWN remounts from re-applying it: this effect runs on
// every mount, and this page unmounts on every navigation away — and on opening a channel page,
// which does not even change `page`. The provider then reports `focusName` as null, so a later
// visit leaves the box exactly as the user left it.
const focusChannelNameRef = useRef(focusChannelName);
focusChannelNameRef.current = focusChannelName;
useEffect(() => {
const name = focusChannelNameRef.current;
if (name) setSearch(name);
}, [focusChannelToken, setSearch]);
if (!name) return;
setSearch(name);
consumeFocus();
}, [focusChannelToken, setSearch, consumeFocus]);
// The header's reset intent clears the search box (skip the initial mount); the tag chips ride in
// `filters.tags`, which goToFullHistory already clears by setting a fresh map.
const resetRef = useRef(filtersResetToken);
@@ -8,8 +8,8 @@
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
// navigationContext (the sole runtime dependency) is mocked, pinned to the channels page.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { ReactNode } from "react";
import { renderHook, act, render as renderRoot } from "@testing-library/react";
import { useEffect, useRef, type ReactNode } from "react";
vi.mock("./navigationContext", () => ({
useNavigation: () => ({ page: "channels" }),
@@ -127,3 +127,85 @@ describe("ChannelsProvider — S8 shareable URL", () => {
});
});
});
// --- the focus-a-channel intent is consumed, not masked -------------------------------------------
// `focusChannel` sends the user to the manager with its name filter seeded. The manager is a page
// chunk: it unmounts on every navigation away, and on opening a channel page (which does not even
// change `page`), and its seeding effect runs again on each mount. So the "already applied" marker
// has to live in the PROVIDER — inside the page it would reset with the page and re-apply an intent
// from earlier in the session over whatever the user has typed into the search box since.
describe("ChannelsProvider — the focus-a-channel intent", () => {
/** Stands in for Channels.tsx:287-297 — the same effect, the same consume call. */
function Manager() {
const { focusName, focusToken } = useChannels();
const { setSearch, consumeFocus } = useChannelsActions();
const nameRef = useRef(focusName);
nameRef.current = focusName;
useEffect(() => {
const name = nameRef.current;
if (!name) return;
setSearch(name);
consumeFocus();
}, [focusToken, setSearch, consumeFocus]);
return null;
}
function Harness({ mounted }: { mounted: boolean }) {
return <>{mounted && <Manager />}</>;
}
it("seeds the search box once, and a later visit does not re-seed it", () => {
let api!: ReturnType<typeof useChannels> & ReturnType<typeof useChannelsActions>;
function Probe() {
api = { ...useChannels(), ...useChannelsActions() };
return null;
}
const { rerender } = renderRoot(
<ChannelsProvider>
<Probe />
<Harness mounted />
</ChannelsProvider>,
);
act(() => api.focusChannel("Kurzgesagt"));
expect(api.search).toBe("Kurzgesagt");
// The user looks for something else instead.
act(() => api.setSearch("veritasium"));
// Leave the manager (the page chunk unmounts) and come back.
rerender(
<ChannelsProvider>
<Probe />
<Harness mounted={false} />
</ChannelsProvider>,
);
rerender(
<ChannelsProvider>
<Probe />
<Harness mounted />
</ChannelsProvider>,
);
expect(api.search).toBe("veritasium");
});
it("re-seeds when the channel is focused again — the token is the intent", () => {
let api!: ReturnType<typeof useChannels> & ReturnType<typeof useChannelsActions>;
function Probe() {
api = { ...useChannels(), ...useChannelsActions() };
return null;
}
renderRoot(
<ChannelsProvider>
<Probe />
<Harness mounted />
</ChannelsProvider>,
);
act(() => api.focusChannel("Kurzgesagt"));
act(() => api.setSearch("veritasium"));
act(() => api.focusChannel("Kurzgesagt")); // the same channel, a new intent
expect(api.search).toBe("Kurzgesagt");
});
});
+21 -2
View File
@@ -183,7 +183,16 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// rather than cleared by an effect when you leave. focusChannel sets the name and navigates in the
// same handler, so the value is live by the time channels renders; everywhere else it reads null,
// one render earlier than the effect used to make it.
const focusName = page === "channels" ? focusNameRaw : null;
//
// But an intent has to be CONSUMED, not merely masked, and the consumed marker has to live HERE.
// Channels is a page chunk that unmounts on every navigation (and on opening a channel page, which
// does not even change `page`), so its seeding effect runs again on each remount. A marker inside
// it would reset with it and re-apply an intent from earlier in the session over whatever the user
// has since typed into the search box. Keyed on the token rather than the name, because
// re-focusing the same channel is a new intent.
const [focusApplied, setFocusApplied] = useState(0);
const consumeFocus = useCallback(() => setFocusApplied(focusToken), [focusToken]);
const focusName = page === "channels" && focusApplied !== focusToken ? focusNameRaw : null;
// Label the entry the manager was REACHED on (setPage pushes a bare { sfPage }), so stepping back
// onto it later restores the tab it belonged to rather than leaving whatever tab is current.
@@ -216,9 +225,19 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
setLayout,
setSort,
focusChannel,
consumeFocus,
goToFullHistory,
}),
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
[
setSearch,
setFilters,
setView,
setLayout,
setSort,
focusChannel,
consumeFocus,
goToFullHistory,
],
);
// S8: mirror the content state (tab + sort + column filters) into the address bar under the namespaced
@@ -351,3 +351,53 @@ describe("PlayerModal — the dialog contract", () => {
}
});
});
// --- the watch toggle belongs to the item it describes -------------------------------------------
// A late writer exists by construction: useYtProgress's cleanup for the OUTGOING item runs after the
// commit that already advanced `active`, and within the finish margin it auto-marks that item
// watched. While the mirror was a bare boolean that write landed last and painted the INCOMING item
// as watched. Keying the mirror by id makes the stray write not this item's, so it is ignored.
describe("PlayerModal — the watch toggle after a queue advance", () => {
const A = vid("A", 0);
const B = vid("B", 0);
beforeEach(() => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
video_id: id,
position_seconds: 0,
}));
});
it("does not inherit the outgoing item's auto-watch when stepping away near the end", async () => {
const onState = vi.fn();
clock.current = 2995; // inside min(FINISH_MARGIN, duration/2) = 10s of 3000
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={onState} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
await settle();
// A really is marked watched — that part is correct and must keep working.
expect(onState.mock.calls).toContainEqual(["A", "watched"]);
// B is not, and its toggle says so. Before the fix both toggles read "watchedUnmark" here.
expect(screen.queryAllByTitle("player.watchedUnmark")).toHaveLength(0);
expect(screen.queryAllByTitle("player.markWatched").length).toBeGreaterThan(0);
});
it("still reflects a watch toggled on the item you are actually on", async () => {
const onState = vi.fn();
clock.current = 0;
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={onState} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!);
expect(onState.mock.calls).toContainEqual(["A", "watched"]);
expect(screen.queryAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0);
});
});
+22 -11
View File
@@ -234,25 +234,36 @@ export default function PlayerModal({
setLiveData(null);
};
// Local mirror of watch status so the toggle reacts instantly; changes are
// propagated to the feed (and server) via onState.
const [status, setStatus] = useState(active.status);
const watched = status === "watched";
// Local mirror of watch status so the toggle reacts instantly; changes are propagated to the feed
// (and server) via onState. The mirror carries the id it DESCRIBES, and a write for any other id
// is ignored rather than displayed — that is the enforcement, and it has to be in code because
// one writer arrives late by construction: useYtProgress's cleanup for the OUTGOING item runs
// after the commit that already advanced `active`, and near the end of a clip it auto-marks that
// item watched (playerModalHooks.ts `maybeAutoWatch`). Keyed, that stray write is simply not this
// item's, so the toggle falls back to `active.status` and no ordering has to be argued about.
// Unkeyed it landed last and painted the NEXT video as watched; a corrective click then sent
// onState(next, "new") for an item that was already new.
const [status, setStatus] = useState<{ id: string; value: VideoStatus }>({
id: active.id,
value: active.status,
});
const watched = (status.id === active.id ? status.value : active.status) === "watched";
const setWatched = (on: boolean) => {
const next = on ? "watched" : "new";
setStatus(next);
setStatus({ id: active.id, value: next });
onState(active.id, next);
};
// On queue advance (active changes), sync local state to the new item — DURING that render, not
// one commit later. As an effect, the frame between showed the new episode's chrome over the
// previous one's watch state and `navigated` flag (currentVideoId still pointed at the old id, so
// the "you have navigated away" affordance flashed on every advance). React discards this pass
// instead of committing it, so there is no such frame; the guard falsifies itself immediately.
// On queue advance (active changes), point `currentVideoId` at the new item — DURING that render,
// not one commit later. As an effect, the frame between still had it on the previous id, so the
// "you have navigated away" affordance flashed on every advance. React discards this pass instead
// of committing it, so there is no such frame; the guard falsifies itself immediately.
//
// The watch status is NOT synced here: it is keyed by id above, which is both stale-proof and
// immune to the late writer this block cannot see (see that comment).
const [syncedTo, setSyncedTo] = useState(active.id);
if (syncedTo !== active.id) {
setSyncedTo(active.id);
setStatus(active.status);
setCurrentVideoId(active.id);
}
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
//
// The chip-overflow popovers are open/closed by a DERIVED flag (`openRaw && hidden > 0`), which is
// what keeps a menu from outliving the "+N" trigger it is anchored to for a frame. The trap the
// derivation sets is that it hides the surface without closing it: floating-ui only reports a close
// for a real event on a mounted surface, so when the lane stops overflowing, the trigger and the
// menu disappear while the raw flag stays true — and the next re-fit that overflows again brings
// back a menu nobody asked for. `useChipOverflowFit` re-decides from a ResizeObserver, so that
// second re-fit needs no user action at all.
//
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
// measurement can only ever produce one answer.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) => (o?.count != null ? `${k}:${o.count}` : k),
}),
};
});
const fit = { visible: 1 };
vi.mock("../lib/useChipOverflowFit", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/useChipOverflowFit")>();
return { ...actual, useChipOverflowFit: () => fit.visible };
});
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
const chip = (key: string): FilterChip => ({ key, label: key, active: false, onToggle: () => {} });
const chips = [chip("alpha"), chip("beta"), chip("gamma")];
// The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never
// happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a
// `hidden: true` query can see it. Presence is the whole question here, not visibility.
const menu = () => screen.queryByRole("menu", { hidden: true });
const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
afterEach(() => {
cleanup();
fit.visible = 1;
});
describe("TagFilterChips — the overflow menu", () => {
it("does not come back on its own once the lane stops overflowing", () => {
const { rerender } = render(<TagFilterChips chips={chips} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
// The lane widens (a resize, the text-size slider, the trailing Clear button unmounting) and
// nothing is collapsed any more: both trigger and menu go.
fit.visible = 3;
rerender(<TagFilterChips chips={chips} />);
expect(menu()).toBeNull();
expect(moreBtn()).toBeNull();
// …and it narrows again, with no user interaction in between. The menu must stay shut, and the
// returning trigger must not claim to be expanded.
fit.visible = 1;
rerender(<TagFilterChips chips={chips} />);
expect(menu()).toBeNull();
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
});
it("still opens and closes on the trigger", () => {
render(<TagFilterChips chips={chips} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
fireEvent.click(moreBtn()!);
expect(menu()).toBeNull();
});
});
@@ -62,7 +62,17 @@ export default function TagFilterChips({
// The popover can only be open while there is a "+N" trigger to anchor it. Derived rather than
// closed by an effect, so the frame in which the last overflowing chip fits again cannot also
// contain a menu hanging off a reference that is already gone.
//
// Deriving alone is NOT enough, and the missing half is the flag itself: floating-ui only reports
// a close for a real event on a mounted surface, so when the lane stops overflowing the trigger
// and the menu just disappear and `openRaw` stays true. The next re-fit that overflows again —
// a window resize, the text-size slider, or the trailing Clear button mounting as a tag is
// toggled; `useChipOverflowFit` re-decides from a ResizeObserver, with no user near this menu —
// would then re-open a menu nobody asked for, with its trigger already `aria-expanded`. So the
// flag is reset in RENDER against the same condition that hides it: convergent (the write
// falsifies it) and, unlike an effect, gone before the frame is painted.
const [openRaw, setOpen] = useState(false);
if (openRaw && hidden === 0) setOpen(false);
const open = openRaw && hidden > 0;
const pop = useAnchoredPopover({
open,
@@ -33,6 +33,10 @@ export interface ChannelsActions {
// "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name
// filter so the channel is isolated. Called from the feed sidebar and the notifications inbox.
focusChannel: (name: string) => void;
// Mark the current focus intent as applied. The manager calls this the moment it seeds its name
// filter, so the intent cannot be re-applied when the page chunk remounts on a later visit and
// overwrite a search the user has typed since.
consumeFocus: () => void;
// Jump to the manager filtered to channels still missing full history (the header sync chip).
goToFullHistory: () => void;
}
@@ -57,6 +61,7 @@ export const ChannelsActionsContext = createContext<ChannelsActions>({
setLayout: () => {},
setSort: () => {},
focusChannel: () => {},
consumeFocus: () => {},
goToFullHistory: () => {},
});
+4
View File
@@ -80,7 +80,11 @@ function Thumb({ job }: { job: DownloadJob }) {
// Remembered BY the URL that failed, not as a value an effect re-syncs: these rows are recycled
// down a long virtualized list, so a boolean-style mirror painted the previous job's fallback
// over the next one's poster for a frame. See ui/Avatar, which had the same defect.
// …and forgotten once `primary` has moved off it, for the same reason ui/Avatar does: a poster
// that 404'd because its file was still being written should be re-requested when the row comes
// back, not written off for the life of the recycled slot. Convergent, so it belongs in render.
const [failedPrimary, setFailedPrimary] = useState<string | null>(null);
if (failedPrimary !== null && failedPrimary !== primary) setFailedPrimary(null);
const src = failedPrimary && failedPrimary === primary ? remote : primary;
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail.
@@ -43,6 +43,9 @@ describe("Avatar · a failed image must not follow the component to the next ite
expect(painted[0]).not.toContain(">Z<");
});
// CHARACTERIZATION: the old boolean-plus-effect passes this too (the rerender is wrapped in act(),
// so its reset effect has run by the time the src is read). The frame-level guard is the test
// above, which the old shape fails; the retry guard is the test below, which it also fails.
it("keeps the fallback for the URL that actually failed, not for whatever is current", () => {
const { rerender } = render(
<Avatar src="https://cdn.test/a.jpg" alt="avatar" fallback="Zed" />,
@@ -56,6 +59,23 @@ describe("Avatar · a failed image must not follow the component to the next ite
);
});
it("retries a URL it comes back to — a 429 is transient, and so is the memory of it", () => {
// The instance is not remounted here on purpose: the player's channel avatar and the Messages
// thread header both swap `src` on a live instance. A memory that outlived the reason for it
// would pin a throttled channel to its initial for the rest of the session.
const { rerender } = render(
<Avatar src="https://cdn.test/a.jpg" alt="avatar" fallback="Zed" />,
);
fireEvent.error(screen.getByRole("img", { name: "avatar" }));
expect(screen.getByText("Z")).toBeTruthy();
rerender(<Avatar src="https://cdn.test/b.jpg" alt="avatar" fallback="Zed" />);
rerender(<Avatar src="https://cdn.test/a.jpg" alt="avatar" fallback="Zed" />);
expect(screen.getByRole("img", { name: "avatar" }).getAttribute("src")).toBe(
"https://cdn.test/a.jpg",
);
});
it("falls back when there is no src at all", () => {
render(<Avatar src={null} alt="avatar" fallback="Zed" />);
expect(screen.queryByRole("img")).toBeNull();
+9
View File
@@ -25,7 +25,16 @@ export default function Avatar({
// one avatar's 429 used to paint the fallback initial over the NEXT item's perfectly good image
// for a frame — React committed the render with the new `src` and the stale `failed`, and only the
// post-paint effect cleared it. Derived state cannot be stale, so there is nothing to reset.
//
// Forgetting the URL once `src` has moved off it is the other half, and it is what keeps the 429
// TRANSIENT: without it the memory outlives the reason for it, and a mounted instance whose `src`
// swaps — the player's channel avatar stepping through a queue, the Messages thread header — would
// show the initial for a throttled channel forever, never re-requesting even after the CDN
// relented. Written in render because it is convergent (the write falsifies its own condition) and
// because an effect would leave one committed frame in which the memory is stale, which is the
// frame this whole component is about.
const [failedSrc, setFailedSrc] = useState<string | null>(null);
if (failedSrc !== null && failedSrc !== src) setFailedSrc(null);
const failed = !!src && failedSrc === src;
if (src && !failed) {
+11 -3
View File
@@ -486,9 +486,17 @@ export function useYtProgress({
};
// ONE PLAYER PER VIDEO is the invariant, and it is why this dep list is deliberately just the
// id: re-running would destroy and rebuild the YouTube iframe, losing the playing position and
// every listener attached to it. Nothing in the body reads a render value — the mutable state it
// needs mid-session (currentIdRef, modeRef, navRef, sessionPosRef, autoMarkedRef,
// errorStreakRef, playerRef) is all behind refs, which is the enforcement, not this comment.
// every listener attached to it.
//
// This body DOES capture render values — `resumeAt`, `setWatched`, `advanceOnEnd`,
// `reapplyStored`, `focusModal`, `qc` — and it must, because each of them is a PER-ITEM
// SNAPSHOT: they describe the video this player was built for, and re-reading them mid-session
// would be the bug, not the fix. `resumeAt` is the clearest case, since persist() writes
// sessionPosRef and so changes it while `activeId` has not moved; the player is already playing
// from it and must not be told again. What genuinely has to stay live mid-session is behind refs
// (currentIdRef, modeRef, navRef, sessionPosRef, autoMarkedRef, errorStreakRef, playerRef) — and
// THAT split is the enforcement: a value that is not a snapshot does not belong in this closure.
//
// useEffectEvent cannot help here the way it does above: YT.Player takes its callbacks as an
// object literal, and an effect event may not be passed around as a value.
// eslint-disable-next-line react-hooks/exhaustive-deps