Ten more suppressions, and React 19 turns out to supply the missing primitive.
useEffectEvent (stable since React 19.2, which S1 brought in) is the canonical answer to the
"bind the listener once, but read the latest values" pattern these hooks were disabling the
checker for. Three of playerModalHooks' four escape hatches become honest dep lists: the
wheel-volume handler, the native-controls handover, and the fullscreen-change sync. The
contract is no longer prose — the compiler enforces it.
The fourth stays, deliberately, and now says why at the site: the YouTube player's lifecycle
effect must run once per video (re-running destroys and rebuilds the iframe), and YT.Player
takes its callbacks as an object literal, which an effect event may not be passed into.
Fixed, not suppressed:
- useDismiss captured onClose when the popover opened, so a caller that re-rendered with a
different handler while it stayed open kept getting the OLD one called. The disabled dep list
asserted the opposite ("read fresh from this run's closure"). Both the handler and the ref
list are read through refs now; three tests, the two that matter mutation-checked.
- usePref wrapped its seed in a useCallback whose dep list had to be excused. useState calls a
lazy initializer exactly once, so the memo was saving nothing; inlined.
- useCardPager clamped an out-of-range page from an effect. Adjusted during render instead —
React discards that pass rather than committing it, so no out-of-range page is ever painted.
- plexPlayerHooks' auto-skip countdown reads its marker from the ref that already existed, so
the dep list names its real inputs. That also made the set-state-in-effect finding go away:
the progress is genuinely timer-driven, and the checker can now see that.
- useUrlMirror and useChipOverflowFit only needed their stable refs listed.
Two test-harness suppressions (a render-phase probe ref, a module-level setState handle) moved
inline with the reason they exist: both exist to observe a hook from outside without changing
what is being measured.
Suppressions 99 -> 89; exhaustive-deps 35 -> 27.
410 lines
18 KiB
TypeScript
410 lines
18 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// A subtitle <track> must not be mounted before the server has something to put in it.
|
|
//
|
|
// Bug hunt 2026-09, item 8: extracting one track out of a big MKV is a full read of the container
|
|
// (154 s for a 17 GB film, measured on prod), and it used to happen inside the <track>'s own
|
|
// request. The browser gives a <track> no visible state, so the player showed nothing at all —
|
|
// which reads as "switching subtitles does nothing", and the reported workaround was to stop/start
|
|
// until the cache happened to be warm. The extraction now runs in the background, publishing a
|
|
// window around the playhead first, and this hook's poll is what decides when there is something to
|
|
// mount and what to say while there isn't.
|
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { createRef, type ReactNode, type MutableRefObject } from "react";
|
|
import { usePlexTracks } from "./plexPlayerHooks";
|
|
import { api, type PlexItemDetail } from "./api";
|
|
import { dismissError, getError } from "./errorDialog";
|
|
import type Hls from "hls.js";
|
|
|
|
const RK = "98614";
|
|
|
|
const detail = (): PlexItemDetail =>
|
|
({
|
|
id: RK,
|
|
duration_seconds: 8580,
|
|
audio_streams: [{ ord: 0, label: "Magyar AC3 5.1", language: "hu", default: true }],
|
|
subtitle_streams: [
|
|
{ ord: 0, label: "Magyar", language: "hu", text: true },
|
|
{ ord: 1, label: "English", language: "en", text: true },
|
|
],
|
|
markers: [],
|
|
}) as unknown as PlexItemDetail;
|
|
|
|
function mount(qc: QueryClient, { sessionK = 0, abs = 3600, loadStart = 0 } = {}) {
|
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
|
);
|
|
return renderHook(
|
|
({ id }: { id: string }) =>
|
|
usePlexTracks({
|
|
id,
|
|
detail: detail(),
|
|
sessionK,
|
|
subOffset: 0,
|
|
subPos: 88,
|
|
loadSession: async () => {},
|
|
patchPrefs: () => {},
|
|
videoRef: createRef<HTMLVideoElement>(),
|
|
hlsRef: createRef<Hls>(),
|
|
multiAudioRef: { current: false },
|
|
audioRef: { current: 0 } as MutableRefObject<number | null>,
|
|
aoffRef: { current: 0 },
|
|
absRef: { current: abs },
|
|
loadStartRef: { current: loadStart },
|
|
}),
|
|
{ wrapper, initialProps: { id: RK } },
|
|
);
|
|
}
|
|
|
|
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
|
|
|
describe("usePlexTracks — the <track> waits for the extraction", () => {
|
|
// vitest runs with globals off, so testing-library's auto-cleanup never registers: unmount by
|
|
// hand or the hook's 250 ms sub-offset debounce fires into a torn-down environment.
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("is not showable while the track is still being extracted", async () => {
|
|
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
|
|
state: "preparing",
|
|
code: null,
|
|
gen: 0,
|
|
});
|
|
const { result } = mount(client());
|
|
result.current.setSubOrd(0);
|
|
await waitFor(() => expect(api.plexSubtitleStatus).toHaveBeenCalled());
|
|
await waitFor(() => expect(result.current.selectedSub?.ord).toBe(0));
|
|
// The selected track exists and is offered in the picker — it just cannot be rendered yet, and
|
|
// the caller must be told so rather than mounting an empty <track>.
|
|
expect(result.current.subShowable).toBe(false);
|
|
expect(result.current.subNotice).toBe("preparing");
|
|
});
|
|
|
|
it("sends the viewer's position, so the extraction windows around where they are", async () => {
|
|
const status = vi
|
|
.spyOn(api, "plexSubtitleStatus")
|
|
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
|
|
const { result } = mount(client());
|
|
result.current.setSubOrd(1);
|
|
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 3600));
|
|
});
|
|
|
|
it("sends where the session was asked to start before playback has ticked, not the head of the film", async () => {
|
|
// A resumed session starts minutes in, but `abs` stays 0 until the first timeupdate — and every
|
|
// poll used to say at=0, so the first window was cut around the head of the film (measured on
|
|
// localdev: a resume at 20:00 polled at=0 for as long as it stayed paused). `sessionK` is no
|
|
// substitute: it is still 0 here, because the session request has not answered yet.
|
|
const status = vi
|
|
.spyOn(api, "plexSubtitleStatus")
|
|
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
|
|
const { result } = mount(client(), { sessionK: 0, abs: 0, loadStart: 1198 });
|
|
result.current.setSubOrd(1);
|
|
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 1198));
|
|
});
|
|
|
|
it("does not carry the track choice to the next item — that item's detail decides it", async () => {
|
|
// Carried over, the old ordinal polled the NEW item (where it may be another language) at the
|
|
// OLD item's position, queueing an extraction of the wrong stretch of the next file.
|
|
const status = vi
|
|
.spyOn(api, "plexSubtitleStatus")
|
|
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
|
|
const { result, rerender } = mount(client());
|
|
result.current.setSubOrd(1);
|
|
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 3600));
|
|
rerender({ id: "NEXT" });
|
|
expect(result.current.subOrd).toBeNull();
|
|
expect(result.current.subNotice).toBeNull();
|
|
await act(async () => {});
|
|
expect(status.mock.calls.some(([id]) => id === "NEXT")).toBe(false);
|
|
});
|
|
|
|
it("becomes showable on a PARTIAL track — a window around the playhead is what is needed now", async () => {
|
|
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
|
|
state: "partial",
|
|
code: null,
|
|
gen: 1757500000000000000,
|
|
});
|
|
const { result } = mount(client());
|
|
result.current.setSubOrd(0);
|
|
await waitFor(() => expect(result.current.subShowable).toBe(true));
|
|
// The generation goes into the <track> key/URL: without it the browser would keep serving the
|
|
// first, short window from its own cache once the background pass widens the track.
|
|
expect(result.current.subGen).toBe(1757500000000000000);
|
|
});
|
|
|
|
it("reports a track that can never be shown, in the bucket the player has a sentence for", async () => {
|
|
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
|
|
state: "failed",
|
|
code: "bitmap",
|
|
gen: 0,
|
|
});
|
|
const { result } = mount(client());
|
|
result.current.setSubOrd(0);
|
|
await waitFor(() => expect(result.current.subNotice).toBe("bitmap"));
|
|
expect(result.current.subShowable).toBe(false);
|
|
});
|
|
|
|
it("folds the server's finer failure codes into 'missing'", async () => {
|
|
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
|
|
state: "failed",
|
|
code: "no_file",
|
|
gen: 0,
|
|
});
|
|
const { result } = mount(client());
|
|
result.current.setSubOrd(0);
|
|
await waitFor(() => expect(result.current.subNotice).toBe("missing"));
|
|
});
|
|
|
|
it("asks nothing at all while subtitles are off", async () => {
|
|
const status = vi
|
|
.spyOn(api, "plexSubtitleStatus")
|
|
.mockResolvedValue({ state: "ready", code: null, gen: 1 });
|
|
const { result } = mount(client());
|
|
await waitFor(() => expect(result.current.textSubs).toHaveLength(2));
|
|
expect(status).not.toHaveBeenCalled();
|
|
expect(result.current.subShowable).toBe(false);
|
|
});
|
|
});
|
|
|
|
// The poll's refetch rules ARE the feature: nothing else turns "preparing" into subtitles on screen,
|
|
// and nothing else stops a settled track from being asked about for the length of the film. Every
|
|
// test above resolves one fixed answer, so none of them ever reached the refetch callback.
|
|
describe("usePlexTracks — when the status poll asks again, and when it stops", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("keeps asking until a window lands, then shows it without the viewer doing anything", async () => {
|
|
const status = vi
|
|
.spyOn(api, "plexSubtitleStatus")
|
|
.mockResolvedValueOnce({ state: "preparing", code: null, gen: 0 })
|
|
.mockResolvedValue({ state: "partial", code: null, gen: 7 });
|
|
const { result } = mount(client());
|
|
result.current.setSubOrd(0);
|
|
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
|
|
expect(result.current.subShowable).toBe(false);
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
await waitFor(() => expect(result.current.subShowable).toBe(true));
|
|
});
|
|
|
|
it.each([
|
|
["the whole track is there", { state: "ready" as const, code: null, gen: 3 }],
|
|
["it can never be shown", { state: "failed" as const, code: "bitmap", gen: 0 }],
|
|
])("stops asking once the answer is settled — %s", async (_, answer) => {
|
|
const status = vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue(answer);
|
|
const { result } = mount(client());
|
|
result.current.setSubOrd(0);
|
|
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
expect(status).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("slows down on a partial track that will not grow on its own, and keeps asking", async () => {
|
|
// The full pass failed and is backed off for an hour: `partial` never turns `ready` by itself,
|
|
// and the 2 s cadence ran for the rest of the film. It still carries the position — reading
|
|
// ahead of an edge depends on it — so it slows rather than stops.
|
|
const status = vi
|
|
.spyOn(api, "plexSubtitleStatus")
|
|
.mockResolvedValue({ state: "partial", code: null, gen: 4, growing: false });
|
|
const { result } = mount(client());
|
|
result.current.setSubOrd(0);
|
|
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
expect(status).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(6000);
|
|
expect(status).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("backs off after a failed request, and still asks again so a blip heals on its own", async () => {
|
|
// A failed request leaves `data` empty; it used to read as "still preparing" and re-ask every
|
|
// second for as long as the player stayed open.
|
|
const status = vi.spyOn(api, "plexSubtitleStatus").mockRejectedValue(new Error("offline"));
|
|
const { result } = mount(client());
|
|
result.current.setSubOrd(0);
|
|
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
|
|
expect(status).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
expect(status).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(6000);
|
|
expect(status).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not raise the global error dialog when it fails — the player says so inline", async () => {
|
|
dismissError();
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ detail: "boom" }), {
|
|
status: 500,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
await expect(api.plexSubtitleStatus(RK, 0, 0)).rejects.toThrow();
|
|
expect(getError()).toBeNull();
|
|
});
|
|
|
|
it("says a failing poll even while an earlier window is still on screen", async () => {
|
|
// react-query keeps the last good answer through a failed refetch, so a `partial` track stayed
|
|
// "showable" and the failure had nowhere to appear: past the window, blank subtitles and nothing
|
|
// on screen saying why — with the request `quiet`, not even the global dialog.
|
|
vi.spyOn(api, "plexSubtitleStatus")
|
|
.mockResolvedValueOnce({ state: "partial", code: null, gen: 5 })
|
|
.mockRejectedValue(new Error("offline"));
|
|
const { result } = mount(client());
|
|
result.current.setSubOrd(0);
|
|
await waitFor(() => expect(result.current.subShowable).toBe(true));
|
|
expect(result.current.subNotice).toBeNull();
|
|
await vi.advanceTimersByTimeAsync(2100);
|
|
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
|
|
expect(result.current.subShowable).toBe(true); // the window it has is still worth showing
|
|
});
|
|
|
|
it("says `unavailable` but keeps asking — and keeps the cues it has on screen", async () => {
|
|
// A Plex hiccup, or a stretch ffmpeg could not read, is the server's answer about NOW. Taken as
|
|
// final it stopped the poll and unmounted the track: no subtitles for the rest of the session,
|
|
// at cached positions too, after the full pass had completed behind it.
|
|
const status = vi
|
|
.spyOn(api, "plexSubtitleStatus")
|
|
.mockResolvedValueOnce({ state: "unavailable", code: "plex_error", gen: 5 })
|
|
.mockResolvedValue({ state: "partial", code: null, gen: 6 });
|
|
const { result } = mount(client());
|
|
result.current.setSubOrd(0);
|
|
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
|
|
expect(result.current.subShowable).toBe(true);
|
|
await vi.advanceTimersByTimeAsync(5100);
|
|
expect(status).toHaveBeenCalledTimes(2);
|
|
await waitFor(() => expect(result.current.subNotice).toBeNull());
|
|
});
|
|
|
|
it("does not mount an `unavailable` track that has no cues anywhere", async () => {
|
|
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
|
|
state: "unavailable",
|
|
code: "plex_error",
|
|
gen: 0,
|
|
});
|
|
const { result } = mount(client());
|
|
result.current.setSubOrd(0);
|
|
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
|
|
expect(result.current.subShowable).toBe(false);
|
|
});
|
|
|
|
it("re-asks the server at once when the viewer picks the track that is already selected", async () => {
|
|
// Same track, same query key: without an explicit refetch "picking it again" did nothing for a
|
|
// poll that was backing off after a failed request.
|
|
const status = vi.spyOn(api, "plexSubtitleStatus").mockRejectedValue(new Error("offline"));
|
|
const { result } = mount(client());
|
|
result.current.setSubOrd(0);
|
|
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
|
|
expect(status).toHaveBeenCalledTimes(1);
|
|
status.mockResolvedValue({ state: "partial", code: null, gen: 2 });
|
|
act(() => result.current.changeSubtitle(0));
|
|
await waitFor(() => expect(status).toHaveBeenCalledTimes(2));
|
|
await waitFor(() => expect(result.current.subShowable).toBe(true));
|
|
});
|
|
});
|
|
|
|
// Rendered for real, not by calling the hook's callback: the failure arrives as the <track>'s own
|
|
// `error` event, which does not bubble — and React 18 attaches no listener of its own to a <track>,
|
|
// so the `onError` this used to be wired through was never called in a browser at all.
|
|
function TrackHarness() {
|
|
const tracks = usePlexTracks({
|
|
id: RK,
|
|
detail: detail(),
|
|
sessionK: 0,
|
|
subOffset: 0,
|
|
subPos: 88,
|
|
loadSession: async () => {},
|
|
patchPrefs: () => {},
|
|
videoRef: createRef<HTMLVideoElement>(),
|
|
hlsRef: createRef<Hls>(),
|
|
multiAudioRef: { current: false },
|
|
audioRef: { current: 0 } as MutableRefObject<number | null>,
|
|
aoffRef: { current: 0 },
|
|
absRef: { current: 3600 },
|
|
loadStartRef: { current: 0 },
|
|
});
|
|
// eslint-disable-next-line react-hooks/immutability -- a TEST probe: the hook's return has to reach the assertions, and a render-phase ref write is the only way to read it from outside without a wrapper that would change what is being measured.
|
|
harness.current = tracks;
|
|
return (
|
|
<video>
|
|
{tracks.subShowable && (
|
|
<track
|
|
key={tracks.subTrackKey}
|
|
data-key={tracks.subTrackKey}
|
|
kind="subtitles"
|
|
{...tracks.subTrackListeners}
|
|
/>
|
|
)}
|
|
</video>
|
|
);
|
|
}
|
|
const harness: { current: ReturnType<typeof usePlexTracks> | null } = { current: null };
|
|
|
|
function renderTrack(qc: QueryClient) {
|
|
const view = render(
|
|
<QueryClientProvider client={qc}>
|
|
<TrackHarness />
|
|
</QueryClientProvider>,
|
|
);
|
|
const track = () => view.container.querySelector("track");
|
|
// What the browser does when the VTT fetch fails: a plain, NON-bubbling `error` on the element.
|
|
const fail = () => act(() => void track()!.dispatchEvent(new Event("error")));
|
|
return { track, fail };
|
|
}
|
|
|
|
describe("usePlexTracks — a <track> whose own VTT fetch failed", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "ready", code: null, gen: 5 });
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("is replaced by a fresh element, after a pause, a couple of times — then reported instead of left blank", async () => {
|
|
// A failed <track> never fetches again, and while the server's answer is unchanged its key was
|
|
// unchanged too — so React kept the dead element and the subtitles stayed silently blank. The
|
|
// pause matters as much: remounting at once spent the whole budget inside a one-second outage.
|
|
const { track, fail } = renderTrack(client());
|
|
act(() => harness.current!.setSubOrd(0));
|
|
await waitFor(() => expect(track()).not.toBeNull());
|
|
for (const pause of [2000, 4000]) {
|
|
const before = track()!.dataset.key;
|
|
fail();
|
|
await vi.advanceTimersByTimeAsync(pause - 100);
|
|
expect(track()!.dataset.key).toBe(before); // not yet: still waiting out the pause
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
await waitFor(() => expect(track()!.dataset.key).not.toBe(before));
|
|
}
|
|
fail();
|
|
await waitFor(() => expect(track()).toBeNull());
|
|
expect(harness.current!.subNotice).toBe("failed");
|
|
});
|
|
|
|
it("gets a fresh budget when the viewer picks the track again", async () => {
|
|
// The give-up belongs to one track generation, and a `ready` track is no longer polled, so no new
|
|
// generation would ever come: turning subtitles off and on used to leave "couldn't be loaded".
|
|
const { track, fail } = renderTrack(client());
|
|
act(() => harness.current!.setSubOrd(0));
|
|
await waitFor(() => expect(track()).not.toBeNull());
|
|
for (let i = 0; i < 3; i++) {
|
|
fail();
|
|
await vi.advanceTimersByTimeAsync(4100);
|
|
}
|
|
await waitFor(() => expect(track()).toBeNull());
|
|
act(() => harness.current!.changeSubtitle(null));
|
|
act(() => harness.current!.changeSubtitle(0));
|
|
await waitFor(() => expect(track()).not.toBeNull());
|
|
expect(harness.current!.subNotice).toBeNull();
|
|
});
|
|
});
|