From e23bf6ce5914ad490f5c0d9d1305c782ed34e9bc Mon Sep 17 00:00:00 2001 From: Minasuki Hikimuna Date: Sun, 5 Jul 2026 10:23:02 +0300 Subject: [PATCH] Fix VideoPlayer source reload under StrictMode React StrictMode replays mount effects in development: the first VideoPlayer source effect records the loaded stream signature and calls video.load(), then the cleanup effect strips the rendered source src attributes and calls video.load() to release the stream. On the second effect pass, lastLoadedSourceRef still matched the same stream signature, so the source effect returned early even though the real DOM source no longer had a src. The media element then reported no available source and never requested /api/stream/video/... despite the endpoint being reachable directly. Make the cached source signature agree with the DOM by tracking the rendered source element, validating its src and MIME type before skipping a reload, restoring missing or stale attributes imperatively when needed, and clearing lastLoadedSourceRef when cleanup strips the source. Add a StrictMode regression test that renders VideoPlayer, lets mount effects replay, and verifies the source src and type are still present. Keep the unknown direct-stream MIME behavior covered so unsupported formats still avoid a misleading type attribute. --- ui/src/components/VideoPlayer.tsx | 31 ++++++++--- ui/src/test/VideoPlayer.test.tsx | 92 +++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 ui/src/test/VideoPlayer.test.tsx diff --git a/ui/src/components/VideoPlayer.tsx b/ui/src/components/VideoPlayer.tsx index 71141339..d2d687ba 100644 --- a/ui/src/components/VideoPlayer.tsx +++ b/ui/src/components/VideoPlayer.tsx @@ -209,6 +209,7 @@ export function VideoPlayer({ const audioFallbackAppliedRef = useRef(false); const [faceOverlayEnabled, setFaceOverlayEnabled] = usePersistedFlag(FACE_OVERLAY_KEY, false); const playbackTracker = useRef(createPlaybackTracker()); + const sourceRef = useRef(null); const hideTimer = useRef>(null); const playTriggered = useRef(false); const sourceRestoreRef = useRef<{ time: number; shouldPlay: boolean } | null>(null); @@ -550,6 +551,7 @@ export function VideoPlayer({ const transcodeResolution = selectedQuality === SOURCE_TRANSCODE_QUALITY ? undefined : selectedQuality; const effectiveStreamUrl = selectedQuality === "Direct" ? streamUrl : videos.transcodeUrl(videoId, transcodeResolution, transcodeStartSec > 0 ? transcodeStartSec : undefined); const effectiveSourceType = selectedQuality === "Direct" ? getVideoSourceMimeType(format) : "video/mp4"; + const effectiveSourceSignature = `${effectiveStreamUrl}|${effectiveSourceType ?? ""}`; useEffect(() => { const v = videoRef.current; @@ -596,13 +598,12 @@ export function VideoPlayer({ pendingAutostartRef.current = true; const video = videoRef.current; - const sourceSignature = `${effectiveStreamUrl}|${format || "mp4"}`; - if (!video || lastLoadedSourceRef.current !== sourceSignature) { + if (!video || lastLoadedSourceRef.current !== effectiveSourceSignature) { return; } video.play().catch(() => {}); - }, [autostart, autostartToken, effectiveStreamUrl, format]); + }, [autostart, autostartToken, effectiveSourceSignature]); useEffect(() => { const handler = () => setPip(document.pictureInPictureElement === videoRef.current); @@ -1033,11 +1034,24 @@ export function VideoPlayer({ return; } - const sourceSignature = `${effectiveStreamUrl}|${format || "mp4"}`; - if (lastLoadedSourceRef.current === sourceSignature) { + const source = sourceRef.current ?? video.querySelector("source"); + const sourceSrcMatches = source?.getAttribute("src") === effectiveStreamUrl; + const sourceTypeMatches = effectiveSourceType + ? source?.getAttribute("type") === effectiveSourceType + : !source?.hasAttribute("type"); + if (lastLoadedSourceRef.current === effectiveSourceSignature && sourceSrcMatches && sourceTypeMatches) { return; } - lastLoadedSourceRef.current = sourceSignature; + lastLoadedSourceRef.current = effectiveSourceSignature; + + if (source) { + source.setAttribute("src", effectiveStreamUrl); + if (effectiveSourceType) { + source.setAttribute("type", effectiveSourceType); + } else { + source.removeAttribute("type"); + } + } const pendingRestore = sourceRestoreRef.current; sourceRestoreRef.current = null; @@ -1073,7 +1087,7 @@ export function VideoPlayer({ return () => { video.removeEventListener("loadedmetadata", handleLoadedMetadata); }; - }, [clip, duration, effectiveResumeTime, effectiveStreamUrl, format, playerVideoStartMinDuration, playerVideoStartPercent, selectedQuality, transcodeStartSec]); + }, [clip, duration, effectiveResumeTime, effectiveSourceSignature, effectiveSourceType, effectiveStreamUrl, playerVideoStartMinDuration, playerVideoStartPercent, selectedQuality, transcodeStartSec]); // Release the media element's network connection when the player unmounts. Without this, leaving a // video (back to the list, or advancing to the next item in a queue when the player is keyed by id) @@ -1093,6 +1107,7 @@ export function VideoPlayer({ video.querySelectorAll("source").forEach((s) => s.removeAttribute("src")); video.removeAttribute("src"); video.load(); + lastLoadedSourceRef.current = null; } catch { // Ignore — element may already be detached. } @@ -1258,7 +1273,7 @@ export function VideoPlayer({ onEndedProp?.(); }} > - + {captions?.map((cap, idx) => ( ({ + useAppConfig: () => ({ config: { ui: {} } }), +})); + +const playMock = vi.fn(() => Promise.resolve()); +const pauseMock = vi.fn(); +const loadMock = vi.fn(); +const localStorageMock = { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), +}; + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +beforeAll(() => { + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + vi.stubGlobal("localStorage", localStorageMock); + + Object.defineProperty(HTMLMediaElement.prototype, "play", { + configurable: true, + writable: true, + value: playMock, + }); + Object.defineProperty(HTMLMediaElement.prototype, "pause", { + configurable: true, + writable: true, + value: pauseMock, + }); + Object.defineProperty(HTMLMediaElement.prototype, "load", { + configurable: true, + writable: true, + value: loadMock, + }); +}); + +describe("VideoPlayer source lifecycle", () => { + beforeEach(() => { + playMock.mockClear(); + pauseMock.mockClear(); + loadMock.mockClear(); + }); + + it("restores the rendered source after StrictMode replays mount effects", async () => { + const { container } = render( + + + , + ); + + const source = container.querySelector("source"); + expect(source).toBeInstanceOf(HTMLSourceElement); + + await waitFor(() => { + expect(source).toHaveAttribute("src", "/api/stream/video/1"); + expect(source).toHaveAttribute("type", "video/mp4"); + }); + }); + + it("does not declare a misleading MIME type for unknown direct video streams", () => { + const { container } = render( + , + ); + + const source = container.querySelector("source"); + expect(source).toBeInstanceOf(HTMLSourceElement); + expect(source).not.toHaveAttribute("type"); + }); +});