Files
siftlode/frontend/src/components/PlexPlayer.strictmode.dom.test.tsx
T
peter f7faa19ee2 fix(plex): the player starts, and resumes after a session-restart seek, when React mounts it twice
The player's "still alive" flag — every play() is gated on it, so a late one cannot start a detached
<video> after Back — was only ever LOWERED, in an effect's cleanup. React may run a cleanup and mount
again (StrictMode does exactly that in development), and then the flag stayed false for the life of
the player: it never started by itself, and a seek back past the loaded region, which restarts the
session, left it paused. Found on localdev (Westworld S01E06: playing → seek back → the picture
settles, paused). Production does not double-mount, which is why only dev showed it — and why
several earlier localdev measurements "stayed paused".

The effect now raises the flag in its setup. A new test renders the real player under StrictMode and
checks it plays once the media is ready; red without the fix. Verified on localdev: it starts by
itself, and keeps playing through a backward session-restart seek.
2026-09-11 03:47:36 +02:00

102 lines
3.7 KiB
TypeScript

// @vitest-environment jsdom
//
// The Plex player must start playing by itself — also when React mounts it, unmounts it and mounts
// it again, which StrictMode does to every component in development.
//
// Bug hunt 2026-09, found on localdev: the player's "still alive" flag (every play() is gated on it,
// so a late one cannot start a detached <video> after Back) was only ever LOWERED, in an effect's
// cleanup. StrictMode's dry-run unmount lowered it for good: the player never started on its own, and
// a seek that restarts the session — back past the loaded region — left it paused. Production does
// not double-mount, which is why only dev showed it.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { StrictMode } from "react";
import { render, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
const played: string[] = [];
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
// jsdom has no media pipeline: record what the player asks of the element instead.
Object.defineProperty(HTMLMediaElement.prototype, "play", {
configurable: true,
value(this: HTMLMediaElement) {
played.push(this.getAttribute("src") ?? "");
return Promise.resolve();
},
});
Object.defineProperty(HTMLMediaElement.prototype, "pause", { configurable: true, value() {} });
Object.defineProperty(HTMLMediaElement.prototype, "load", { configurable: true, value() {} });
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
configurable: true,
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
played.length = 0;
});
const detail = {
id: "99079",
title: "The Adversary",
type: "episode",
duration_seconds: 3434,
status: "new",
position_seconds: 0,
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
subtitle_streams: [],
markers: [],
} as unknown as PlexItemDetail;
describe("PlexPlayer under StrictMode", () => {
it("starts playing by itself once the media is ready", async () => {
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
// A direct-play session: the element gets the file URL and plays on `loadedmetadata`. (hls.js is
// not supported under jsdom, so this is the path the test can drive; the HLS path gates its
// play() on the very same flag.)
vi.spyOn(api, "plexSession").mockResolvedValue({
mode: "direct",
url: "/api/plex/direct/99079",
start_s: 0,
} as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<StrictMode>
<QueryClientProvider client={qc}>
<PlexPlayer itemId="99079" onClose={() => {}} />
</QueryClientProvider>
</StrictMode>,
);
const video = await waitFor(() => {
const v = document.querySelector("video");
expect(v?.getAttribute("src")).toBe("/api/plex/direct/99079");
return v!;
});
video.dispatchEvent(new Event("loadedmetadata"));
await waitFor(() => expect(played).toContain("/api/plex/direct/99079"));
});
});