From e9c1fe9ae93ab54afc3c9da211675634c08900e4 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 3 Jul 2026 10:29:48 +0300 Subject: [PATCH 1/9] Bump --- src/app/components/content/IsaacVideo.tsx | 85 +++++++++++++++++-- .../elements/content/IsaacVideo.test.tsx | 46 ++++++++++ 2 files changed, 125 insertions(+), 6 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index e4cef68096..03df2cd830 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -324,6 +324,23 @@ export function saveVideoProgress(userStorageScope: string | null, videoId: stri } } +/** + * TODO(#855) diagnostic logging for the video KPI flow. Gated by a localStorage flag so it produces no noise for + * normal users but can be enabled on any environment (incl. staging prod builds) by running in the console: + * localStorage.setItem("isaacVideoDebug", "1") + * Remove once the end-to-end VIDEO_60_PERCENT_WATCHED flow is confirmed. + */ +export function videoDebugLog(message: string, data?: Record): void { + try { + if (globalThis.localStorage?.getItem("isaacVideoDebug") === "1") { + // eslint-disable-next-line no-console + console.info(`[video-kpi] ${message}`, data ?? ""); + } + } catch { + // ignore (e.g. localStorage unavailable) + } +} + /** * Log video events to the backend */ @@ -335,9 +352,24 @@ export async function logVideoEvent( dispatch({ type: ACTION_TYPE.LOG_EVENT, eventDetails }); } + videoDebugLog("logVideoEvent -> POST /log", { + type: eventDetails.type, + videoId: eventDetails.videoId, + watchPercent: eventDetails.watchPercent, + watchedSeconds: eventDetails.watchedSeconds, + videoDurationSeconds: eventDetails.videoDurationSeconds, + dispatched: Boolean(dispatch), + }); + try { await api.logger.log(eventDetails); + videoDebugLog("logVideoEvent POST /log succeeded", { type: eventDetails.type, videoId: eventDetails.videoId }); } catch (error) { + videoDebugLog("logVideoEvent POST /log FAILED", { + type: eventDetails.type, + videoId: eventDetails.videoId, + error: error instanceof Error ? error.message : String(error), + }); if (process.env.NODE_ENV === "development") { console.warn("Failed to log video event:", error); } @@ -527,19 +559,57 @@ export function IsaacVideo(props: IsaacVideoProps) { if (progressReference.current.totalVideoDurationInSeconds === totalVideoDurationInSeconds) return; progressReference.current.totalVideoDurationInSeconds = totalVideoDurationInSeconds; saveVideoProgress(userStorageScope, canonicalVideoId, progressReference.current); + videoDebugLog("video duration captured", { videoId: canonicalVideoId, totalVideoDurationInSeconds }); }, [canonicalVideoId, userStorageScope], ); const checkIf60PercentWatchedAndLog = useCallback( - (videoId: string, videoUrl: string) => { - if (!userStorageScope || !canonicalVideoId || progressReference.current.thresholdLogged) return; + (videoId: string, videoUrl: string, liveCurrentTime?: number) => { + if (!userStorageScope || !canonicalVideoId || progressReference.current.thresholdLogged) { + videoDebugLog("checkIf60% skipped", { + videoId, + reason: !userStorageScope ? "not-logged-in" : !canonicalVideoId ? "no-video-id" : "already-logged-this-video", + }); + return; + } const totalVideoDurationInSeconds = progressReference.current.totalVideoDurationInSeconds; - if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return; + if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) { + videoDebugLog("checkIf60% skipped", { videoId, reason: "no-duration-yet", totalVideoDurationInSeconds }); + return; + } + + // Include the segment currently being watched (not yet committed while playing straight through) so the + // KPI fires as soon as cumulative watch time crosses the threshold, without needing a pause/seek/end/unmount. + const segments = [...progressReference.current.segments]; + const openSegmentStart = progressReference.current.currentSegmentStart; + if ( + progressReference.current.isPlaying && + isValidNumber(openSegmentStart) && + isValidNumber(liveCurrentTime) && + liveCurrentTime > openSegmentStart + ) { + segments.push({ watchedSegmentStart: openSegmentStart, watchedSegmentEnd: liveCurrentTime }); + } - const uniqueWatchedSeconds = getUniqueWatchedSeconds(progressReference.current.segments); + const uniqueWatchedSeconds = getUniqueWatchedSeconds(mergeSegments(segments)); const watchPercent = getWatchPercent(uniqueWatchedSeconds, totalVideoDurationInSeconds); - if (watchPercent < VIDEO_WATCH_THRESHOLD) return; + if (watchPercent < VIDEO_WATCH_THRESHOLD) { + videoDebugLog("checkIf60% below threshold", { + videoId, + watchPercent: Number(watchPercent.toFixed(3)), + uniqueWatchedSeconds: Number(uniqueWatchedSeconds.toFixed(1)), + totalVideoDurationInSeconds, + }); + return; + } + + videoDebugLog("checkIf60% THRESHOLD REACHED -> logging", { + videoId, + watchPercent: Number(watchPercent.toFixed(3)), + uniqueWatchedSeconds: Number(uniqueWatchedSeconds.toFixed(1)), + totalVideoDurationInSeconds, + }); progressReference.current.thresholdLogged = true; saveVideoProgress(userStorageScope, canonicalVideoId, progressReference.current); @@ -607,8 +677,11 @@ export function IsaacVideo(props: IsaacVideoProps) { startCurrentSegment(currentTime); } progressReference.current.lastKnownTime = currentTime; + // Evaluate the KPI live during playback (counting the open segment) so it fires without needing a + // pause/seek/end/navigation — otherwise a straight watch-through or a tab close never logs the event. + checkIf60PercentWatchedAndLog(videoId, videoUrl, currentTime); }, - [closeCurrentSegment, startCurrentSegment], + [checkIf60PercentWatchedAndLog, closeCurrentSegment, startCurrentSegment], ); const logPlayerEvent = useCallback( diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index 2d8d3762c6..66fa780c5e 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -238,6 +238,52 @@ describe("YouTube player handlers", () => { expect(dispatchMock).not.toHaveBeenCalled(); }); + + it("logs VIDEO_60_PERCENT_WATCHED during continuous playback, without a pause/seek/end", async () => { + // Regression: the KPI must fire live from the playback poll while the segment is still open. Previously it + // was only evaluated when a segment closed (pause/seek/end/unmount), so a straight watch-through never logged. + let currentTime = 0; + const advancingPlayer = { + getVideoUrl: () => youtubeSrc, + getCurrentTime: () => currentTime, + getDuration: () => 120, + }; + + renderYouTubeVideo(); + await flushPromises(); + + const dispatchMock = store.dispatch as jest.Mock; + + // Fake timers must be active BEFORE play so the 1s poll interval is registered as a fake timer we can advance. + jest.useFakeTimers(); + try { + // Start playback at t=0 (opens a segment and starts the 1s poll timer). + await act(async () => { + capturedPlayerConfig?.events?.onStateChange?.({ target: advancingPlayer, data: 1 }); + }); + dispatchMock.mockClear(); + + // Advance the player one second per poll tick (below the seek tolerance) up past 60% of 120s (= 72s). + for (let second = 1; second <= 75; second++) { + currentTime = second; + act(() => { + jest.advanceTimersByTime(1000); + }); + } + } finally { + jest.useRealTimers(); + } + + type LoggedAction = { type?: string; eventDetails?: { type?: string; videoId?: string; watchPercent?: number } }; + const thresholdLog = dispatchMock.mock.calls.find(([action]) => { + const a = action as LoggedAction; + return a?.type === ACTION_TYPE.LOG_EVENT && a?.eventDetails?.type === "VIDEO_60_PERCENT_WATCHED"; + }); + expect(thresholdLog).toBeDefined(); + const eventDetails = (thresholdLog?.[0] as LoggedAction).eventDetails; + expect(eventDetails?.videoId).toBe(youtubeVideoId); + expect(eventDetails?.watchPercent).toBeGreaterThanOrEqual(0.6); + }); }); describe("Wistia helpers", () => { From d0f90eb5d683eabf47bfbd7472186dfd161aaee1 Mon Sep 17 00:00:00 2001 From: Marius Date: Mon, 6 Jul 2026 15:12:40 +0300 Subject: [PATCH 2/9] Bump --- src/app/components/content/IsaacVideo.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 03df2cd830..3d1e87fb8f 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -325,19 +325,15 @@ export function saveVideoProgress(userStorageScope: string | null, videoId: stri } /** - * TODO(#855) diagnostic logging for the video KPI flow. Gated by a localStorage flag so it produces no noise for - * normal users but can be enabled on any environment (incl. staging prod builds) by running in the console: - * localStorage.setItem("isaacVideoDebug", "1") - * Remove once the end-to-end VIDEO_60_PERCENT_WATCHED flow is confirmed. + * TODO(#855) TEMPORARY diagnostic logging for the video KPI flow — currently logs unconditionally on every + * environment (including production). MUST be removed (this block and its call sites) before the prod release. */ export function videoDebugLog(message: string, data?: Record): void { try { - if (globalThis.localStorage?.getItem("isaacVideoDebug") === "1") { - // eslint-disable-next-line no-console - console.info(`[video-kpi] ${message}`, data ?? ""); - } + // eslint-disable-next-line no-console + console.info(`[video-kpi] ${message}`, data ?? ""); } catch { - // ignore (e.g. localStorage unavailable) + // ignore (e.g. console unavailable) } } From 15c48fe0a98e3c376e6740847d1b3e58d49885f6 Mon Sep 17 00:00:00 2001 From: Marius Date: Mon, 6 Jul 2026 15:44:16 +0300 Subject: [PATCH 3/9] Bump --- src/app/components/content/IsaacVideo.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 3d1e87fb8f..10fafde240 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -673,11 +673,27 @@ export function IsaacVideo(props: IsaacVideoProps) { startCurrentSegment(currentTime); } progressReference.current.lastKnownTime = currentTime; + + // TODO(#855) diagnostic: log every playback tick regardless of login state, so the watch progress and the + // logged-in/anonymous state are visible even for anonymous sessions (the KPI itself stays logged-in only). + const tickTotalDuration = progressReference.current.totalVideoDurationInSeconds; + const positionPercent = + isValidNumber(tickTotalDuration) && tickTotalDuration > 0 ? currentTime / tickTotalDuration : null; + videoDebugLog("playback tick", { + videoId, + loggedIn: Boolean(userStorageScope), + userStorageScope, + currentTime: Number(currentTime.toFixed(1)), + totalVideoDurationInSeconds: tickTotalDuration, + positionPercent: positionPercent == null ? null : Number(positionPercent.toFixed(3)), + crossed60ByPosition: positionPercent != null && positionPercent >= VIDEO_WATCH_THRESHOLD, + }); + // Evaluate the KPI live during playback (counting the open segment) so it fires without needing a // pause/seek/end/navigation — otherwise a straight watch-through or a tab close never logs the event. checkIf60PercentWatchedAndLog(videoId, videoUrl, currentTime); }, - [checkIf60PercentWatchedAndLog, closeCurrentSegment, startCurrentSegment], + [checkIf60PercentWatchedAndLog, closeCurrentSegment, startCurrentSegment, userStorageScope], ); const logPlayerEvent = useCallback( From 9441135bef4870e3b01a842f160eae22c09edde9 Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 7 Jul 2026 10:59:33 +0300 Subject: [PATCH 4/9] Bump --- src/app/components/content/IsaacVideo.tsx | 172 +++++++++++++++++- .../elements/content/IsaacVideo.test.tsx | 45 +++++ 2 files changed, 210 insertions(+), 7 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 10fafde240..65605c8d38 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useContext, useRef } from "react"; import { VideoDTO } from "../../../IsaacApiTypes"; import { selectors, useAppDispatch, useAppSelector } from "../../state"; -import { NOT_FOUND, api, ACTION_TYPE } from "../../services"; +import { NOT_FOUND, api, ACTION_TYPE, API_PATH } from "../../services"; import ReactGA from "react-ga4"; import { AccordionSectionContext } from "../../../IsaacAppTypes"; @@ -76,12 +76,23 @@ declare global { Wistia?: { [key: string]: unknown; }; + // Wistia's E-v1.js embed exposes a `_wq` command queue; pushing a handler with an + // `onReady` callback yields a video API object we can read the true duration from. + _wq?: Array<{ id: string; onReady?: (video: WistiaVideoApi) => void; [key: string]: unknown }>; } // eslint-disable-next-line no-var var YT: Window["YT"]; // eslint-disable-next-line no-var var Wistia: Window["Wistia"]; + // eslint-disable-next-line no-var + var _wq: Window["_wq"]; +} + +interface WistiaVideoApi { + duration?: () => number; + time?: () => number; + [key: string]: unknown; } // Constants @@ -372,6 +383,38 @@ export async function logVideoEvent( } } +/** + * Reliably POST a video-engagement event to /log when the page is being hidden/unloaded. + * + * A normal in-page XHR/axios POST is cancelled by the browser on navigation or tab close, which + * silently drops a threshold reached at the last moment. fetch with `keepalive: true` matches the + * existing axios request (same JSON content type + credentials, so it satisfies the identical + * cookie auth and CORS) but is allowed to outlive the document. Returns whether the request was + * dispatched (not whether the server accepted it — there is no response to await during unload). + */ +export function sendVideoEngagementBeacon(eventDetails: VideoEventDetails): boolean { + try { + void fetch(`${API_PATH}/log`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(eventDetails), + keepalive: true, + }); + videoDebugLog("sendVideoEngagementBeacon dispatched", { + videoId: eventDetails.videoId, + watchPercent: eventDetails.watchPercent, + }); + return true; + } catch (error) { + videoDebugLog("sendVideoEngagementBeacon FAILED", { + videoId: eventDetails.videoId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + /** * Create video event details object */ @@ -521,6 +564,10 @@ export function IsaacVideo(props: IsaacVideoProps) { const wistiaIframeRef = useRef(null); const youtubePlayerRef = useRef(null); const youtubePollTimerRef = useRef | null>(null); + // Prevents re-sending VIDEO_60_PERCENT_WATCHED while an attempt is in flight. Because the KPI is + // now only persisted (thresholdLogged) after the backend confirms, without this guard the + // per-tick playback check would fire duplicate requests until the first one resolved. + const thresholdSendInFlightRef = useRef(false); const platform = React.useMemo(() => (src ? detectPlatform(src) : null), [src]); const isYouTube = platform === "youtube"; @@ -562,10 +609,21 @@ export function IsaacVideo(props: IsaacVideoProps) { const checkIf60PercentWatchedAndLog = useCallback( (videoId: string, videoUrl: string, liveCurrentTime?: number) => { - if (!userStorageScope || !canonicalVideoId || progressReference.current.thresholdLogged) { + if ( + !userStorageScope || + !canonicalVideoId || + progressReference.current.thresholdLogged || + thresholdSendInFlightRef.current + ) { videoDebugLog("checkIf60% skipped", { videoId, - reason: !userStorageScope ? "not-logged-in" : !canonicalVideoId ? "no-video-id" : "already-logged-this-video", + reason: !userStorageScope + ? "not-logged-in" + : !canonicalVideoId + ? "no-video-id" + : progressReference.current.thresholdLogged + ? "already-logged-this-video" + : "send-in-flight", }); return; } @@ -607,16 +665,36 @@ export function IsaacVideo(props: IsaacVideoProps) { totalVideoDurationInSeconds, }); - progressReference.current.thresholdLogged = true; - saveVideoProgress(userStorageScope, canonicalVideoId, progressReference.current); - const eventDetails = createEventDetails("VIDEO_60_PERCENT_WATCHED", videoUrl, videoId, { pageId, videoDurationSeconds: totalVideoDurationInSeconds, watchedSeconds: uniqueWatchedSeconds, watchPercent, }); - logVideoEvent(eventDetails, dispatch); + + // Block duplicate sends from later playback ticks until this attempt resolves. + thresholdSendInFlightRef.current = true; + // Keep the redux LOG_EVENT dispatch (analytics + observability), but only persist + // thresholdLogged once the backend has accepted the event. Previously the flag was set + // optimistically before the POST, so any failure (transient network, or a backend that + // wasn't accepting the event yet) permanently suppressed the KPI for that user+video. + dispatch({ type: ACTION_TYPE.LOG_EVENT, eventDetails }); + api.logger + .log(eventDetails) + .then(() => { + progressReference.current.thresholdLogged = true; + saveVideoProgress(userStorageScope, canonicalVideoId, progressReference.current); + videoDebugLog("VIDEO_60_PERCENT_WATCHED recorded; thresholdLogged persisted", { videoId }); + }) + .catch((error) => { + videoDebugLog("VIDEO_60_PERCENT_WATCHED POST failed; will retry on next update", { + videoId, + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + thresholdSendInFlightRef.current = false; + }); }, [canonicalVideoId, dispatch, pageId, userStorageScope], ); @@ -965,6 +1043,86 @@ export function IsaacVideo(props: IsaacVideoProps) { }; }, [closeCurrentSegment, youtubeVideoId]); + // Unload-safe backstop: when the page is hidden/closed, flush the open segment and, if the 60% + // threshold is met but not yet logged, send it via keepalive fetch — a normal POST is cancelled + // on unload. This covers a viewer who crosses the threshold and immediately leaves the page. + React.useEffect(() => { + if (!userStorageScope || !canonicalVideoId) return; + const videoUrl = embedSrc || ""; + + const flushThreshold = (reason: string) => { + if (progressReference.current.thresholdLogged || thresholdSendInFlightRef.current) return; + const totalVideoDurationInSeconds = progressReference.current.totalVideoDurationInSeconds; + if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return; + + const segments = [...progressReference.current.segments]; + const openSegmentStart = progressReference.current.currentSegmentStart; + const openSegmentEnd = progressReference.current.lastKnownTime; + if ( + progressReference.current.isPlaying && + isValidNumber(openSegmentStart) && + isValidNumber(openSegmentEnd) && + openSegmentEnd > openSegmentStart + ) { + segments.push({ watchedSegmentStart: openSegmentStart, watchedSegmentEnd: openSegmentEnd }); + } + const uniqueWatchedSeconds = getUniqueWatchedSeconds(mergeSegments(segments)); + const watchPercent = getWatchPercent(uniqueWatchedSeconds, totalVideoDurationInSeconds); + if (watchPercent < VIDEO_WATCH_THRESHOLD) return; + + const eventDetails = createEventDetails("VIDEO_60_PERCENT_WATCHED", videoUrl, canonicalVideoId, { + pageId, + videoDurationSeconds: totalVideoDurationInSeconds, + watchedSeconds: uniqueWatchedSeconds, + watchPercent, + }); + videoDebugLog("flushThreshold sending on page hide", { reason, videoId: canonicalVideoId, watchPercent }); + dispatch({ type: ACTION_TYPE.LOG_EVENT, eventDetails }); + // Beacon is fire-and-forget; mark logged optimistically so a following hide event does not + // double-send. A keepalive request is reliably queued once dispatched. + if (sendVideoEngagementBeacon(eventDetails)) { + progressReference.current.thresholdLogged = true; + saveVideoProgress(userStorageScope, canonicalVideoId, progressReference.current); + } + }; + + const onPageHide = () => flushThreshold("pagehide"); + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") flushThreshold("visibilitychange:hidden"); + }; + + globalThis.addEventListener("pagehide", onPageHide); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + globalThis.removeEventListener("pagehide", onPageHide); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [userStorageScope, canonicalVideoId, embedSrc, pageId, dispatch]); + + // Wistia: read the true duration via the Wistia JS API. The postMessage "duration" field is not + // always present, and without a duration the coverage/threshold calculation is skipped — so + // Wistia videos could otherwise never fire VIDEO_60_PERCENT_WATCHED. + React.useEffect(() => { + if (!isWistia || !wistiaVideoId) return; + const wistiaQueue = (globalThis._wq = globalThis._wq || []); + wistiaQueue.push({ + id: wistiaVideoId, + onReady: (video: WistiaVideoApi) => { + try { + const duration = video.duration?.(); + videoDebugLog("Wistia onReady duration", { wistiaVideoId, duration }); + if (isValidNumber(duration) && duration > 0) { + setTotalVideoDurationIfPresent(duration); + } + } catch (error) { + videoDebugLog("Wistia duration read failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }, + }); + }, [isWistia, wistiaVideoId, setTotalVideoDurationIfPresent]); + const detailsForPrintOut =
{altTextToUse}
; const accordionSectionContext = useContext(AccordionSectionContext); diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index 66fa780c5e..2f7fbc3541 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -22,6 +22,7 @@ import { processWistiaMessage, rewrite, saveVideoProgress, + sendVideoEngagementBeacon, updateWistiaTimeFromArgs, updateWistiaTimeFromEventData, } from "../../../../app/components/content/IsaacVideo"; @@ -1409,3 +1410,47 @@ describe("pauseAllVideos", () => { iframe.remove(); }); }); + +describe("sendVideoEngagementBeacon", () => { + const eventDetails = { + type: "VIDEO_60_PERCENT_WATCHED" as const, + videoId: "test123ABCde", + videoUrl: "https://www.youtube.com/watch?v=test123ABCde", + videoDurationSeconds: 120, + watchedSeconds: 80, + watchPercent: 0.66, + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("POSTs the event to /log via fetch with keepalive so it survives page unload", () => { + const fetchMock = jest.fn(() => Promise.resolve({ ok: true } as Response)); + jest.spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch); + + const dispatched = sendVideoEngagementBeacon(eventDetails); + + expect(dispatched).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toMatch(/\/log$/); + expect(init.method).toBe("POST"); + expect(init.keepalive).toBe(true); + expect(init.credentials).toBe("include"); + expect(init.headers).toMatchObject({ "Content-Type": "application/json" }); + expect(JSON.parse(init.body as string)).toMatchObject({ + type: "VIDEO_60_PERCENT_WATCHED", + videoId: "test123ABCde", + }); + }); + + it("returns false and does not throw when fetch is unavailable", () => { + jest.spyOn(globalThis, "fetch").mockImplementation(() => { + throw new Error("fetch unavailable"); + }); + + expect(() => sendVideoEngagementBeacon(eventDetails)).not.toThrow(); + expect(sendVideoEngagementBeacon(eventDetails)).toBe(false); + }); +}); From 1920eafab1103a3511547495bfcbe951b5c531ee Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 8 Jul 2026 08:28:01 +0300 Subject: [PATCH 5/9] Bump --- src/app/components/content/IsaacVideo.tsx | 29 +++++++++++++++++++--- src/app/components/navigation/IsaacApp.tsx | 3 +++ src/app/services/api.ts | 20 ++++++++++++++- src/app/services/index.ts | 1 + src/app/state/actions/index.tsx | 13 ++++++++++ 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 65605c8d38..04fbf61dfa 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -596,6 +596,18 @@ export function IsaacVideo(props: IsaacVideoProps) { progressScopeAndVideoRef.current = { userStorageScope, canonicalVideoId }; } + // TODO(#855) TEMPORARY diagnostic: log the resolved tracking scope whenever it changes, so it is + // obvious whether the 60% KPI is enabled on this video and, if not, exactly why. + React.useEffect(() => { + videoDebugLog("scope resolved", { + userStorageScope, + canonicalVideoId, + loggedIn: Boolean(user), + trackingEnabled: Boolean(userStorageScope && canonicalVideoId), + reason: !userStorageScope ? "not-logged-in" : !canonicalVideoId ? "no-canonical-video-id" : "enabled", + }); + }, [userStorageScope, canonicalVideoId, user]); + const setTotalVideoDurationIfPresent = useCallback( (totalVideoDurationInSeconds: number | null | undefined) => { if (!canonicalVideoId || !isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return; @@ -701,13 +713,24 @@ export function IsaacVideo(props: IsaacVideoProps) { const appendSegment = useCallback( (segmentStart: number, segmentEnd: number, videoId: string, videoUrl: string) => { - if (!userStorageScope) return; + // TODO(#855) TEMPORARY diagnostic: log each condition that gates whether a watched segment is + // recorded (and therefore whether the 60% check can eventually fire), with the deciding values. + if (!userStorageScope) { + videoDebugLog("appendSegment SKIPPED", { reason: "not-logged-in", segmentStart, segmentEnd }); + return; + } const totalVideoDurationInSeconds = progressReference.current.totalVideoDurationInSeconds; - if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return; + if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) { + videoDebugLog("appendSegment SKIPPED", { reason: "duration-unknown", totalVideoDurationInSeconds }); + return; + } const clampedStart = clampVideoProgressValue(segmentStart, 0, totalVideoDurationInSeconds); const clampedEnd = clampVideoProgressValue(segmentEnd, 0, totalVideoDurationInSeconds); - if (clampedEnd - clampedStart < 0.5) return; + if (clampedEnd - clampedStart < 0.5) { + videoDebugLog("appendSegment SKIPPED", { reason: "segment-too-short", clampedStart, clampedEnd }); + return; + } progressReference.current.segments = mergeSegments([ ...progressReference.current.segments, diff --git a/src/app/components/navigation/IsaacApp.tsx b/src/app/components/navigation/IsaacApp.tsx index 6f7f6705e9..608375ae78 100644 --- a/src/app/components/navigation/IsaacApp.tsx +++ b/src/app/components/navigation/IsaacApp.tsx @@ -12,6 +12,7 @@ import { } from "../../state"; import { Route, Router, Switch } from "react-router-dom"; import ErrorClear from "./ErrorClear"; +import { AuthDebugLogger } from "./AuthDebugLogger"; import { Footer } from "./Footer"; import { Question } from "../pages/Question"; import { Concept } from "../pages/Concept"; @@ -156,6 +157,8 @@ export const IsaacApp = () => { // Render return ( + {/* TODO(#855) TEMPORARY diagnostic: logs auth/user state on every navigation. Remove before prod. */} +
diff --git a/src/app/services/api.ts b/src/app/services/api.ts index 22f84f30f7..ed04e43a45 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -1,5 +1,6 @@ import axios, { AxiosPromise } from "axios"; import { API_PATH, EventStageFilter, EventTypeFilter, securePadCredentials, securePadPasswordReset, TAG_ID } from "./"; +import { debugLog } from "./debug"; import * as ApiTypes from "../../IsaacApiTypes"; import { AuthenticationProvider, @@ -518,7 +519,24 @@ export const api = { }, logger: { log: (eventDetails: object): AxiosPromise => { - return endpoint.post(`/log`, eventDetails); + // TODO(#855) TEMPORARY diagnostic: trace every event POSTed to /log (payload + result). + // This is the single choke point all client log events pass through (LOG_EVENT redux action + // and IsaacVideo's play/pause/ended/60% logging), so it captures them all. + const eventType = (eventDetails as { type?: string })?.type; + debugLog("backend-log", "POST /log →", eventDetails); + const request = endpoint.post(`/log`, eventDetails); + // Side-branch handlers only — the original `request` promise is returned unchanged so callers + // still receive success/failure exactly as before. + request + .then((response) => debugLog("backend-log", "POST /log OK", { status: response.status, type: eventType })) + .catch((error) => + debugLog("backend-log", "POST /log FAILED", { + status: error?.response?.status, + type: eventType, + message: error?.message, + }), + ); + return request; }, }, websockets: { diff --git a/src/app/services/index.ts b/src/app/services/index.ts index 8563708e54..d75ff0fa69 100644 --- a/src/app/services/index.ts +++ b/src/app/services/index.ts @@ -1,4 +1,5 @@ // No internal app dependencies +export * from "./debug"; export * from "./localStorage"; export * from "./siteConstants"; export * from "./demoTools"; diff --git a/src/app/state/actions/index.tsx b/src/app/state/actions/index.tsx index 2e6e2945f7..5f7995672d 100644 --- a/src/app/state/actions/index.tsx +++ b/src/app/state/actions/index.tsx @@ -3,6 +3,7 @@ import { ACTION_TYPE, api, API_REQUEST_FAILURE_MESSAGE, + debugLog, atLeastOne, augmentEvent, DOCUMENT_TYPE, @@ -196,13 +197,25 @@ export const getUserPreferences = () => async (dispatch: Dispatch) => { export const requestCurrentUser = () => async (dispatch: Dispatch) => { dispatch({ type: ACTION_TYPE.CURRENT_USER_REQUEST }); + // TODO(#855) TEMPORARY diagnostic: trace the current_user lifecycle so we can see exactly when + // and why the app flips to anonymous (which disables the video KPI). + debugLog("auth", "current_user request"); try { // Request the user const currentUser = await api.users.getCurrent(); // Now with that information request auth settings and preferences asynchronously await Promise.all([dispatch(getUserAuthSettings() as any), dispatch(getUserPreferences() as any)]); + debugLog("auth", "current_user OK", { + userId: currentUser.data?.id, + role: currentUser.data?.role, + loggedIn: true, + }); dispatch({ type: ACTION_TYPE.CURRENT_USER_RESPONSE_SUCCESS, user: currentUser.data }); } catch (e) { + debugLog("auth", "current_user FAILED (app is now anonymous)", { + status: (e as { response?: { status?: number } })?.response?.status, + message: (e as Error)?.message, + }); dispatch({ type: ACTION_TYPE.CURRENT_USER_RESPONSE_FAILURE }); } }; From 6a6d1f626d21aad77dd276fd32729b153f9cb21b Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 8 Jul 2026 08:33:26 +0300 Subject: [PATCH 6/9] Add missing debug.ts + AuthDebugLogger.tsx (fix CI: unresolved imports) --- .../components/navigation/AuthDebugLogger.tsx | 37 +++++++++++++++++++ src/app/services/debug.ts | 19 ++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/app/components/navigation/AuthDebugLogger.tsx create mode 100644 src/app/services/debug.ts diff --git a/src/app/components/navigation/AuthDebugLogger.tsx b/src/app/components/navigation/AuthDebugLogger.tsx new file mode 100644 index 0000000000..cf20b22070 --- /dev/null +++ b/src/app/components/navigation/AuthDebugLogger.tsx @@ -0,0 +1,37 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { selectors, useAppSelector } from "../../state"; +import { debugLog } from "../../services"; + +/** + * TODO(#855) TEMPORARY diagnostic component. + * + * Logs the app's auth/user view on every navigation (and whenever the user state changes) so we can + * see, per page, whether the app considers the user logged in. This is what gates the video KPI: + * `loggedInOrNull === null` ⇒ IsaacVideo `userStorageScope === null` ⇒ 60% tracking disabled. + * + * Renders nothing. Remove before the production release. + */ +export const AuthDebugLogger = () => { + const location = useLocation(); + const user = useAppSelector(selectors.user.orNull); + const loggedInUser = useAppSelector(selectors.user.loggedInOrNull); + + useEffect(() => { + const userId = loggedInUser ? loggedInUser.id : undefined; + const userStorageScope = userId == null ? null : String(userId); + debugLog("auth", "page visit", { + path: location.pathname, + loggedIn: user?.loggedIn ?? null, + requesting: (user as { requesting?: boolean } | null)?.requesting ?? false, + userId, + role: loggedInUser ? loggedInUser.role : undefined, + loggedInOrNull: loggedInUser ? userId : null, + // exactly what IsaacVideo computes; null here means the 60% KPI is disabled on this page + userStorageScope, + videoKpiEnabled: Boolean(userStorageScope), + }); + }, [location.pathname, user, loggedInUser]); + + return null; +}; diff --git a/src/app/services/debug.ts b/src/app/services/debug.ts new file mode 100644 index 0000000000..7e34891b6f --- /dev/null +++ b/src/app/services/debug.ts @@ -0,0 +1,19 @@ +/** + * TODO(#855) TEMPORARY diagnostic logging. + * + * Unconditional console output used to trace, in the browser, exactly what the code sees while we + * debug why VIDEO_60_PERCENT_WATCHED is not reaching the backend: the auth/user state on every + * page, every event POSTed to /log (payload + result), and the video-KPI decision path including + * the conditions that gate whether an event is sent or skipped. + * + * This is intentionally always-on (not behind a flag) and never logs PII beyond user id + role. + * Remove this file and all `debugLog(` call sites before the production release. + */ +export function debugLog(scope: string, message: string, data?: unknown): void { + try { + // eslint-disable-next-line no-console + console.info(`[${scope}] ${message}`, data ?? ""); + } catch { + // ignore (e.g. console unavailable) + } +} From 2dc176dfe853ee9beda67e860d00a997c01c28ee Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 8 Jul 2026 09:15:58 +0300 Subject: [PATCH 7/9] Bump --- src/app/components/content/IsaacVideo.tsx | 186 ++++++++++++++++++++-- 1 file changed, 175 insertions(+), 11 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 04fbf61dfa..de7ab778cc 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -564,6 +564,12 @@ export function IsaacVideo(props: IsaacVideoProps) { const wistiaIframeRef = useRef(null); const youtubePlayerRef = useRef(null); const youtubePollTimerRef = useRef | null>(null); + // The YouTube iframe API is loaded `async` (public/index.html), so `globalThis.YT` is often not + // yet defined when this component's div mounts. The ref callback captures the node here; a waiter + // effect then constructs the player as soon as YT is available. `youtubePlayerCreatedRef` guards + // against constructing twice (ref callback re-fire + waiter poll). + const youtubeNodeRef = useRef(null); + const youtubePlayerCreatedRef = useRef(false); // Prevents re-sending VIDEO_60_PERCENT_WATCHED while an attempt is in flight. Because the KPI is // now only persisted (thresholdLogged) after the backend confirms, without this guard the // per-tick playback check would fire duplicate requests until the first one resolved. @@ -745,12 +751,25 @@ export function IsaacVideo(props: IsaacVideoProps) { const startCurrentSegment = useCallback((segmentStart: number) => { progressReference.current.currentSegmentStart = segmentStart; progressReference.current.lastKnownTime = segmentStart; // this will set a baseline time when a new segment starts, so that we can detect seeks + // TODO(#855) diagnostic: mark the exact position a new watched segment opens. + videoDebugLog("startCurrentSegment", { segmentStart: Number(segmentStart.toFixed(1)) }); }, []); const closeCurrentSegment = useCallback( (segmentEnd: number, videoUrl: string, videoId: string) => { const currentSegmentStart = progressReference.current.currentSegmentStart; - if (!isValidNumber(currentSegmentStart)) return; + if (!isValidNumber(currentSegmentStart)) { + // TODO(#855) diagnostic: closing with no open segment usually means play/pause bookkeeping got out of sync. + videoDebugLog("closeCurrentSegment SKIPPED", { + reason: "no-open-segment", + segmentEnd: Number(segmentEnd.toFixed(1)), + }); + return; + } + videoDebugLog("closeCurrentSegment", { + from: Number(currentSegmentStart.toFixed(1)), + to: Number(segmentEnd.toFixed(1)), + }); appendSegment(currentSegmentStart, segmentEnd, videoId, videoUrl); progressReference.current.currentSegmentStart = null; }, @@ -760,9 +779,22 @@ export function IsaacVideo(props: IsaacVideoProps) { // this function is used to update the playback progress of the video, and detect seeks by comparing the current time with the last known time. const updatePlaybackProgress = useCallback( (currentTime: number, videoUrl: string, videoId: string) => { - if (!isValidNumber(currentTime)) return; + if (!isValidNumber(currentTime)) { + // TODO(#855) diagnostic: getCurrentTime() returned a non-number (player not ready?). + videoDebugLog("updatePlaybackProgress IGNORED", { reason: "current-time-not-a-number", currentTime }); + return; + } const lastKnownTime = progressReference.current.lastKnownTime; if (!progressReference.current.isPlaying || !isValidNumber(lastKnownTime)) { + // TODO(#855) diagnostic: this is the silent branch — a tick arrives but is dropped before any + // "playback tick" log because we think we're not playing or have no baseline time. If ticks + // vanish here while the video is visibly playing, isPlaying never got set true on PLAYING. + videoDebugLog("updatePlaybackProgress IGNORED", { + reason: !progressReference.current.isPlaying ? "not-playing" : "no-baseline-time", + isPlaying: progressReference.current.isPlaying, + lastKnownTime, + currentTime: Number(currentTime.toFixed(1)), + }); progressReference.current.lastKnownTime = currentTime; return; } @@ -770,6 +802,12 @@ export function IsaacVideo(props: IsaacVideoProps) { const diff = currentTime - lastKnownTime; const isSeek = Math.abs(diff) > SEEK_DETECTION_TOLERANCE_SECONDS; if (isSeek) { + // TODO(#855) diagnostic: a seek splits the open segment; log it so jumps vs. straight play are visible. + videoDebugLog("seek detected", { + from: Number(lastKnownTime.toFixed(1)), + to: Number(currentTime.toFixed(1)), + diff: Number(diff.toFixed(1)), + }); closeCurrentSegment(lastKnownTime, videoUrl, videoId); startCurrentSegment(currentTime); } @@ -811,12 +849,21 @@ export function IsaacVideo(props: IsaacVideoProps) { // Load Wistia API script React.useEffect(() => { - if (!isWistia || globalThis.Wistia) return; + if (!isWistia) return; + if (globalThis.Wistia) { + // TODO(#855) diagnostic: Wistia API already present; no injection needed. + videoDebugLog("Wistia API already loaded"); + return; + } const script = document.createElement("script"); script.src = "https://fast.wistia.com/assets/external/E-v1.js"; script.async = true; + script.addEventListener("load", () => videoDebugLog("Wistia API script LOADED")); + script.addEventListener("error", () => videoDebugLog("Wistia API script FAILED to load")); document.body.appendChild(script); + // TODO(#855) diagnostic: injected the Wistia embed script. + videoDebugLog("Wistia API script injected"); }, [isWistia]); // Wistia: postMessage API — play/pause/end and time ticks feed the same segment tracker as YouTube @@ -854,6 +901,14 @@ export function IsaacVideo(props: IsaacVideoProps) { } const eventType = WISTIA_EVENT_TYPE_MAP[eventName.toLowerCase()]; + // TODO(#855) diagnostic: log every Wistia lifecycle event, mapped type included. If mapped events + // never appear while the video plays, the postMessage bindings never took (see "Wistia bindings sent"). + videoDebugLog("Wistia event", { + eventName, + mappedType: eventType ?? "(unmapped/ignored)", + eventTime: Number(eventTime.toFixed(1)), + duration: totalVideoDurationInSeconds, + }); if (!eventType) return; if (eventType === "VIDEO_PLAY") { @@ -915,6 +970,12 @@ export function IsaacVideo(props: IsaacVideoProps) { "https://fast.wistia.net", ); }); + // TODO(#855) diagnostic: bind messages posted to the Wistia iframe. Without these, no Wistia + // events arrive and the tracker stays silent after "scope resolved". + videoDebugLog("Wistia bindings sent", { wistiaVideoId, events: eventsToTrack }); + } else { + // TODO(#855) diagnostic: iframe had no contentWindow after the 1s delay — bindings never sent. + videoDebugLog("Wistia bindings SKIPPED", { reason: "no-iframe-contentWindow", wistiaVideoId }); } }; @@ -945,31 +1006,68 @@ export function IsaacVideo(props: IsaacVideoProps) { if (!youtubePollTimerRef.current) return; globalThis.clearInterval(youtubePollTimerRef.current); youtubePollTimerRef.current = null; + // TODO(#855) diagnostic: the 1s progress poll has stopped (pause/end/unmount). + videoDebugLog("YouTube poll timer STOPPED"); }, []); const pollYouTubePlayerProgress = useCallback(() => { const player = youtubePlayerRef.current; - if (!player || !youtubeVideoId) return; - updatePlaybackProgress(player.getCurrentTime(), player.getVideoUrl(), youtubeVideoId); + if (!player || !youtubeVideoId) { + // TODO(#855) diagnostic: poll fired but we have no player/videoId to read from. + videoDebugLog("YouTube poll SKIPPED", { hasPlayer: Boolean(player), youtubeVideoId }); + return; + } + const currentTime = player.getCurrentTime(); + // TODO(#855) diagnostic: raw poll reading, before updatePlaybackProgress decides to keep or drop it. + videoDebugLog("YouTube poll", { + currentTime: isValidNumber(currentTime) ? Number(currentTime.toFixed(1)) : currentTime, + }); + updatePlaybackProgress(currentTime, player.getVideoUrl(), youtubeVideoId); }, [updatePlaybackProgress, youtubeVideoId]); const startYouTubePollTimer = useCallback(() => { stopYouTubePollTimer(); youtubePollTimerRef.current = globalThis.setInterval(pollYouTubePlayerProgress, 1000); + // TODO(#855) diagnostic: the 1s progress poll has started (should follow every PLAYING). + videoDebugLog("YouTube poll timer STARTED"); }, [pollYouTubePlayerProgress, stopYouTubePollTimer]); const handleYouTubePlayerReady = useCallback( (event: YouTubeEvent) => { youtubePlayerRef.current = event.target; - setTotalVideoDurationIfPresent(event.target.getDuration()); + const duration = event.target.getDuration(); + // TODO(#855) diagnostic: onReady fired — player object exists and duration should be known. + videoDebugLog("YouTube onReady", { youtubeVideoId, duration }); + setTotalVideoDurationIfPresent(duration); }, - [setTotalVideoDurationIfPresent], + [setTotalVideoDurationIfPresent, youtubeVideoId], ); const handleYouTubePlayerStateChange = useCallback( (event: YouTubeEvent) => { const YT = globalThis.YT; - if (!YT || !youtubeVideoId) return; + // TODO(#855) diagnostic: name the raw YT state so the console reads playing/paused/ended/buffering/cued + // instead of -1..5. -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering, 5 cued. + const stateName = + ( + { [-1]: "unstarted", 0: "ended", 1: "playing", 2: "paused", 3: "buffering", 5: "cued" } as Record< + number, + string + > + )[event.data] ?? String(event.data); + videoDebugLog("YouTube onStateChange", { + youtubeVideoId, + rawState: event.data, + stateName, + ytPresent: Boolean(YT), + }); + if (!YT || !youtubeVideoId) { + // TODO(#855) diagnostic: if this fires we can never process play/pause/end — the whole KPI is dead here. + videoDebugLog("handleYouTubePlayerStateChange BAILED", { + reason: !YT ? "no-YT-global" : "no-youtube-video-id", + }); + return; + } youtubePlayerRef.current = event.target; setTotalVideoDurationIfPresent(event.target.getDuration()); @@ -1016,14 +1114,32 @@ export function IsaacVideo(props: IsaacVideoProps) { ], ); - // YouTube video initialization + // YouTube video initialization. The ref callback only *captures the node* — it does NOT construct the + // player, because YT may not be loaded yet at mount. Construction happens in the waiter effect below. const youtubeRef = useCallback( (node: HTMLDivElement | null) => { - const YT = globalThis.YT; - if (!node || !youtubeVideoId || !YT) return; + youtubeNodeRef.current = node; + // TODO(#855) diagnostic: entry point of the YouTube KPI chain. `ytPresent:false` here just means the + // async iframe API had not loaded yet — the waiter effect will construct the player once it does. + videoDebugLog("youtubeRef invoked (node captured)", { + hasNode: Boolean(node), + youtubeVideoId, + ytPresent: Boolean(globalThis.YT), + }); + }, + [youtubeVideoId], + ); + const createYouTubePlayer = useCallback( + (node: HTMLDivElement) => { + const YT = globalThis.YT; + if (!YT || !youtubeVideoId || youtubePlayerCreatedRef.current) return; + // Commit before the async YT.ready callback so the waiter poll cannot construct a second player. + youtubePlayerCreatedRef.current = true; try { + videoDebugLog("createYouTubePlayer -> YT.ready(register)", { youtubeVideoId }); YT.ready(() => { + videoDebugLog("YT.ready fired -> new YT.Player", { youtubeVideoId }); new YT.Player(node, { videoId: youtubeVideoId, playerVars: { @@ -1040,8 +1156,10 @@ export function IsaacVideo(props: IsaacVideoProps) { }); }); } catch (error) { + youtubePlayerCreatedRef.current = false; // allow a later retry const errorMessage = error instanceof Error ? error.message : "problem with YT library"; console.error("Error with YouTube library: ", error); + videoDebugLog("createYouTubePlayer THREW", { youtubeVideoId, error: errorMessage }); ReactGA.gtag("event", "exception", { description: `youtube_error: ${errorMessage}`, fatal: false, @@ -1051,6 +1169,52 @@ export function IsaacVideo(props: IsaacVideoProps) { [handleYouTubePlayerReady, handleYouTubePlayerStateChange, youtubeVideoId], ); + // Waiter: construct the YouTube player as soon as BOTH the div is mounted and the async YT API is + // loaded. This is the actual fix for a console that stops at "scope resolved" — previously, if YT + // wasn't ready at mount, the player was never created and there was no retry. + React.useEffect(() => { + if (!isYouTube || !youtubeVideoId) return; + youtubePlayerCreatedRef.current = false; // new video id -> allow a fresh construction + + const tryCreate = (): boolean => { + const node = youtubeNodeRef.current; + if (youtubePlayerCreatedRef.current) return true; + if (!node) { + videoDebugLog("YT waiter: waiting for node"); + return false; + } + if (!globalThis.YT) { + videoDebugLog("YT waiter: waiting for YT API to load"); + return false; + } + videoDebugLog("YT waiter: node + YT ready -> creating player", { youtubeVideoId }); + createYouTubePlayer(node); + return true; + }; + + if (tryCreate()) return; + + const pollTimer = globalThis.setInterval(() => { + if (tryCreate()) globalThis.clearInterval(pollTimer); + }, 250); + // Give up after a bounded wait so we do not poll forever if the API script failed to load at all. + const giveUpTimer = globalThis.setTimeout(() => { + globalThis.clearInterval(pollTimer); + if (!youtubePlayerCreatedRef.current) { + videoDebugLog("YT waiter: GAVE UP (YT API never loaded / node never mounted)", { + youtubeVideoId, + hadNode: Boolean(youtubeNodeRef.current), + ytPresent: Boolean(globalThis.YT), + }); + } + }, 15000); + + return () => { + globalThis.clearInterval(pollTimer); + globalThis.clearTimeout(giveUpTimer); + }; + }, [isYouTube, youtubeVideoId, createYouTubePlayer]); + // Close any open YouTube segment and stop polling when leaving the page or changing video React.useEffect(() => { return () => { From 11ffdd948b0ef62db96174bc8ab28ba4c0281aa7 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 8 Jul 2026 09:43:16 +0300 Subject: [PATCH 8/9] Bump --- src/app/components/content/IsaacVideo.tsx | 133 +++++++++++++++------- 1 file changed, 95 insertions(+), 38 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index de7ab778cc..52e46fd39f 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -55,6 +55,7 @@ interface YouTubePlayer { getVideoUrl: () => string; getCurrentTime: () => number; getDuration: () => number; + destroy?: () => void; } interface YouTubeEvent { @@ -65,7 +66,7 @@ interface YouTubeEvent { declare global { interface Window { YT?: { - Player: new (element: HTMLElement, config: unknown) => void; + Player: new (element: HTMLElement, config: unknown) => YouTubePlayer; ready: (callback: () => void) => void; PlayerState: { PLAYING: number; @@ -776,6 +777,11 @@ export function IsaacVideo(props: IsaacVideoProps) { [appendSegment], ); + // Latest closeCurrentSegment, read by the YouTube teardown below without making that effect depend on + // (and therefore re-run + tear the player down on) every callback identity change. + const closeCurrentSegmentRef = useRef(closeCurrentSegment); + closeCurrentSegmentRef.current = closeCurrentSegment; + // this function is used to update the playback progress of the video, and detect seeks by comparing the current time with the last known time. const updatePlaybackProgress = useCallback( (currentTime: number, videoUrl: string, videoId: string) => { @@ -1130,8 +1136,28 @@ export function IsaacVideo(props: IsaacVideoProps) { [youtubeVideoId], ); + const handleYouTubePlayerError = useCallback( + (event: YouTubeEvent) => { + // TODO(#855) diagnostic: YouTube reported a player error (2 invalid id, 5 html5, 100 not found, + // 101/150 embedding disabled). The KPI cannot run if the video refuses to embed/play. + videoDebugLog("YouTube onError", { youtubeVideoId, errorCode: event.data }); + }, + [youtubeVideoId], + ); + + // The player is constructed exactly once, but auth (userStorageScope) and the page doc (pageId) can + // resolve AFTER construction. So we register STABLE wrapper handlers that always dispatch to the latest + // handler via these refs — otherwise the player would forever call handlers that closed over the + // logged-out / no-pageId state, and never log play/pause or the KPI once the user is actually scoped. + const handleYouTubePlayerReadyRef = useRef(handleYouTubePlayerReady); + handleYouTubePlayerReadyRef.current = handleYouTubePlayerReady; + const handleYouTubePlayerStateChangeRef = useRef(handleYouTubePlayerStateChange); + handleYouTubePlayerStateChangeRef.current = handleYouTubePlayerStateChange; + const handleYouTubePlayerErrorRef = useRef(handleYouTubePlayerError); + handleYouTubePlayerErrorRef.current = handleYouTubePlayerError; + const createYouTubePlayer = useCallback( - (node: HTMLDivElement) => { + (container: HTMLDivElement) => { const YT = globalThis.YT; if (!YT || !youtubeVideoId || youtubePlayerCreatedRef.current) return; // Commit before the async YT.ready callback so the waiter poll cannot construct a second player. @@ -1139,8 +1165,16 @@ export function IsaacVideo(props: IsaacVideoProps) { try { videoDebugLog("createYouTubePlayer -> YT.ready(register)", { youtubeVideoId }); YT.ready(() => { + // Hand YT a plain child node to replace with its