From cc367d3bc6ec3528ce3c86103fa1f27d4c83e7b2 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 22 Jul 2026 13:33:53 +0000 Subject: [PATCH 1/6] Fix video ref tracking to prevent unnecessary re-renders --- templates/clips/app/routes/r.$recordingId.tsx | 16 ++++++++++------ templates/clips/app/routes/share.$shareId.tsx | 13 +++++++++---- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/templates/clips/app/routes/r.$recordingId.tsx b/templates/clips/app/routes/r.$recordingId.tsx index ec1b28ede7..a7c72fe58e 100644 --- a/templates/clips/app/routes/r.$recordingId.tsx +++ b/templates/clips/app/routes/r.$recordingId.tsx @@ -800,16 +800,20 @@ export default function RecordingPage() { usePlayerShortcuts({ playerRef, chapters }); - const tracking = useViewTracking({ - recordingId: recordingId ?? "", - videoRef: { + const trackedVideoRef = useMemo( + () => ({ get current() { return playerRef.current?.video ?? null; }, - } as any, + }), + [], + ) as React.RefObject; + + const tracking = useViewTracking({ + recordingId: recordingId ?? "", + videoRef: trackedVideoRef, durationMs: recording?.durationMs ?? 0, - // Skip tracking for the owner — they shouldn't inflate their own views. - disabled: role === "owner", + disabled: role === "owner", // Skip tracking for the owner: they shouldn't inflate their own views. }); if (!recordingId) return null; diff --git a/templates/clips/app/routes/share.$shareId.tsx b/templates/clips/app/routes/share.$shareId.tsx index 4b124ee5cf..b921fd3d45 100644 --- a/templates/clips/app/routes/share.$shareId.tsx +++ b/templates/clips/app/routes/share.$shareId.tsx @@ -579,13 +579,18 @@ export default function ShareRoute() { usePlayerShortcuts({ playerRef }); - const tracking = useViewTracking({ - recordingId: shareId ?? "", - videoRef: { + const trackedVideoRef = useMemo( + () => ({ get current() { return playerRef.current?.video ?? null; }, - } as any, + }), + [], + ) as React.RefObject; + + const tracking = useViewTracking({ + recordingId: shareId ?? "", + videoRef: trackedVideoRef, durationMs: recording?.durationMs ?? 0, trackOpenWithoutVideo: isLoomEmbedBacked, }); From 90daff382762fa27d0bdb1fdf677df32b9c777aa Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 22 Jul 2026 16:44:51 +0000 Subject: [PATCH 2/6] fix(clips): reattach view tracking listeners when the video element changes Guard the tracking effect by comparing the actual video DOM node instead of relying on caller-side ref identity, so an edit-mode unmount/remount on /r/:recordingId correctly reattaches listeners instead of leaving them on the detached element. --- .../clips/app/hooks/use-view-tracking.ts | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/templates/clips/app/hooks/use-view-tracking.ts b/templates/clips/app/hooks/use-view-tracking.ts index 651410f31f..6ac05c30ea 100644 --- a/templates/clips/app/hooks/use-view-tracking.ts +++ b/templates/clips/app/hooks/use-view-tracking.ts @@ -46,10 +46,17 @@ export interface UseViewTrackingOpts { * Wires up the view-event tracker for a player instance. Fires a "view-start" * on mount, then throttled "watch-progress" every 5s while playing, plus * seek/pause/resume events and a final flush on unmount. + * + * Runs on every render (no dependency array) but only re-attaches listeners + * when `videoRef.current` actually changes identity — e.g. an edit-mode + * toggle unmounts/remounts the player. Reading the latest opts through a ref + * keeps long-lived listener closures (which may outlive several renders) + * from ever using stale values like `durationMs`. */ export function useViewTracking(opts: UseViewTrackingOpts) { - const { recordingId, videoRef, durationMs, disabled, trackOpenWithoutVideo } = - opts; + const optsRef = useRef(opts); + optsRef.current = opts; + const watchMsRef = useRef(0); const lastTickRef = useRef(null); const startedRef = useRef(false); @@ -57,16 +64,36 @@ export function useViewTracking(opts: UseViewTrackingOpts) { const lastSentProgressRef = useRef(0); const maxPctRef = useRef(0); const viewSessionRef = useRef(null); + const attachedVideoRef = useRef(null); + const hasAttachedRef = useRef(false); + const cleanupRef = useRef<() => void>(() => {}); useEffect(() => { - if (disabled) return; + const { recordingId, videoRef, disabled, trackOpenWithoutVideo } = + optsRef.current; + + if (disabled) { + cleanupRef.current(); + cleanupRef.current = () => {}; + hasAttachedRef.current = true; + attachedVideoRef.current = null; + return; + } + const video = videoRef.current; + if (hasAttachedRef.current && video === attachedVideoRef.current) return; + + cleanupRef.current(); + hasAttachedRef.current = true; + attachedVideoRef.current = video; + if (!video) { if ( !trackOpenWithoutVideo || !recordingId || openTrackedRecordingRef.current === recordingId ) { + cleanupRef.current = () => {}; return; } openTrackedRecordingRef.current = recordingId; @@ -87,6 +114,7 @@ export function useViewTracking(opts: UseViewTrackingOpts) { payload: { source: "iframe-open" }, }), }).catch(() => {}); + cleanupRef.current = () => {}; return; } @@ -105,6 +133,7 @@ export function useViewTracking(opts: UseViewTrackingOpts) { | "reaction", extra?: Record, ) { + const { recordingId, videoRef, durationMs } = optsRef.current; const v = videoRef.current; if (!v) return; const completedPct = @@ -180,7 +209,7 @@ export function useViewTracking(opts: UseViewTrackingOpts) { video.addEventListener("seeked", onSeek); video.addEventListener("ended", onEnded); - return () => { + cleanupRef.current = () => { video.removeEventListener("play", onPlay); video.removeEventListener("pause", onPause); video.removeEventListener("seeked", onSeek); @@ -189,7 +218,11 @@ export function useViewTracking(opts: UseViewTrackingOpts) { // Flush final progress. if (startedRef.current) post("watch-progress"); }; - }, [recordingId, videoRef, durationMs, disabled, trackOpenWithoutVideo]); + }); + + useEffect(() => { + return () => cleanupRef.current(); + }, []); return { reportCtaClick: () => { @@ -198,7 +231,7 @@ export function useViewTracking(opts: UseViewTrackingOpts) { keepalive: true, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - recordingId, + recordingId: optsRef.current.recordingId, kind: "cta-click", sessionId: getSessionId(), }), @@ -210,7 +243,7 @@ export function useViewTracking(opts: UseViewTrackingOpts) { keepalive: true, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - recordingId, + recordingId: optsRef.current.recordingId, kind: "reaction", sessionId: getSessionId(), payload: { emoji }, From 9ade92e84fd8789e9d361862a34ebf56ea56741c Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 22 Jul 2026 17:04:47 +0000 Subject: [PATCH 3/6] fix(clips): reset view-tracking session state when recording or embed mode changes The reattachment guard only compared the video DOM node, so a route that reuses the same player instance for a different recordingId (or a Loom share where trackOpenWithoutVideo flips true after the native video stays null) would either bleed stale watch counters into the new recording or skip the no-video iframe-open tracking entirely. Track recordingId and trackOpenWithoutVideo alongside the video element, and reset all per-session counters whenever any of them change. --- .../clips/app/hooks/use-view-tracking.ts | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/templates/clips/app/hooks/use-view-tracking.ts b/templates/clips/app/hooks/use-view-tracking.ts index 6ac05c30ea..b732659884 100644 --- a/templates/clips/app/hooks/use-view-tracking.ts +++ b/templates/clips/app/hooks/use-view-tracking.ts @@ -48,10 +48,12 @@ export interface UseViewTrackingOpts { * seek/pause/resume events and a final flush on unmount. * * Runs on every render (no dependency array) but only re-attaches listeners - * when `videoRef.current` actually changes identity — e.g. an edit-mode - * toggle unmounts/remounts the player. Reading the latest opts through a ref - * keeps long-lived listener closures (which may outlive several renders) - * from ever using stale values like `durationMs`. + * when the video element, recordingId, or trackOpenWithoutVideo actually + * change — e.g. an edit-mode toggle unmounts/remounts the player, or a + * route reuses the same player instance for a different recording. Reading + * the latest opts through a ref keeps long-lived listener closures (which + * may outlive several renders) from ever using stale values like + * `durationMs`. */ export function useViewTracking(opts: UseViewTrackingOpts) { const optsRef = useRef(opts); @@ -65,6 +67,8 @@ export function useViewTracking(opts: UseViewTrackingOpts) { const maxPctRef = useRef(0); const viewSessionRef = useRef(null); const attachedVideoRef = useRef(null); + const attachedRecordingIdRef = useRef(null); + const attachedTrackOpenRef = useRef(false); const hasAttachedRef = useRef(false); const cleanupRef = useRef<() => void>(() => {}); @@ -77,15 +81,33 @@ export function useViewTracking(opts: UseViewTrackingOpts) { cleanupRef.current = () => {}; hasAttachedRef.current = true; attachedVideoRef.current = null; + attachedRecordingIdRef.current = recordingId; + attachedTrackOpenRef.current = !!trackOpenWithoutVideo; return; } const video = videoRef.current; - if (hasAttachedRef.current && video === attachedVideoRef.current) return; + const unchanged = + hasAttachedRef.current && + video === attachedVideoRef.current && + recordingId === attachedRecordingIdRef.current && + !!trackOpenWithoutVideo === attachedTrackOpenRef.current; + if (unchanged) return; cleanupRef.current(); hasAttachedRef.current = true; attachedVideoRef.current = video; + attachedRecordingIdRef.current = recordingId; + attachedTrackOpenRef.current = !!trackOpenWithoutVideo; + + // A different video element, recording, or embed mode starts a fresh + // tracking session — last session's counters must not carry over. + watchMsRef.current = 0; + lastTickRef.current = null; + startedRef.current = false; + lastSentProgressRef.current = 0; + maxPctRef.current = 0; + viewSessionRef.current = null; if (!video) { if ( From 09eca10d39b00a4ce72618df9e38704b552ba2e3 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 22 Jul 2026 21:48:41 +0000 Subject: [PATCH 4/6] fix(clips): correct final-flush recordingId and StrictMode remount handling - post() now uses the recordingId captured at attach time for the outgoing event body instead of re-reading optsRef.current, which by cleanup time may already reflect a newer recordingId from a subsequent render, mislabeling the previous session's final watch-progress flush. - The unmount effect now resets hasAttachedRef/attachedVideoRef/ attachedRecordingIdRef/attachedTrackOpenRef after running cleanup, so a React StrictMode dev mount->cleanup->remount cycle (or any real remount reusing the same video/recording) re-attaches listeners instead of seeing 'unchanged' and silently leaving tracking disabled. --- templates/clips/app/hooks/use-view-tracking.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/templates/clips/app/hooks/use-view-tracking.ts b/templates/clips/app/hooks/use-view-tracking.ts index b732659884..a0153bb467 100644 --- a/templates/clips/app/hooks/use-view-tracking.ts +++ b/templates/clips/app/hooks/use-view-tracking.ts @@ -155,7 +155,7 @@ export function useViewTracking(opts: UseViewTrackingOpts) { | "reaction", extra?: Record, ) { - const { recordingId, videoRef, durationMs } = optsRef.current; + const { videoRef, durationMs } = optsRef.current; const v = videoRef.current; if (!v) return; const completedPct = @@ -243,7 +243,17 @@ export function useViewTracking(opts: UseViewTrackingOpts) { }); useEffect(() => { - return () => cleanupRef.current(); + return () => { + cleanupRef.current(); + cleanupRef.current = () => {}; + // Reset attachment identity so a StrictMode dev remount (or any real + // remount that reuses the same video/recording) re-attaches instead + // of seeing "unchanged" and silently skipping setup. + hasAttachedRef.current = false; + attachedVideoRef.current = null; + attachedRecordingIdRef.current = null; + attachedTrackOpenRef.current = false; + }; }, []); return { From ff4534f953b3d77e4ec26f2cdfbb31dfcf1312a2 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 22 Jul 2026 22:16:56 +0000 Subject: [PATCH 5/6] fix(clips): use session-scoped duration for flushes, reset iframe-open dedup on unmount - post() now reads durationMs from a dedicated durationMsRef instead of optsRef.current, which is updated to a new session's duration BEFORE the old session's cleanup flush runs. durationMsRef only advances to the new value after the old session's teardown, and stays live-synced during an unchanged session so an async-loaded duration is still picked up without a full reattach. - The unmount effect now also resets openTrackedRecordingRef, so a later reopen of the same no-video (Loom-backed) recording fires its view-start again instead of being permanently deduped by a stale guard left over from the previous attachment. --- .../clips/app/hooks/use-view-tracking.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/templates/clips/app/hooks/use-view-tracking.ts b/templates/clips/app/hooks/use-view-tracking.ts index a0153bb467..5c1df293fa 100644 --- a/templates/clips/app/hooks/use-view-tracking.ts +++ b/templates/clips/app/hooks/use-view-tracking.ts @@ -71,10 +71,16 @@ export function useViewTracking(opts: UseViewTrackingOpts) { const attachedTrackOpenRef = useRef(false); const hasAttachedRef = useRef(false); const cleanupRef = useRef<() => void>(() => {}); + const durationMsRef = useRef(opts.durationMs); useEffect(() => { - const { recordingId, videoRef, disabled, trackOpenWithoutVideo } = - optsRef.current; + const { + recordingId, + videoRef, + disabled, + trackOpenWithoutVideo, + durationMs, + } = optsRef.current; if (disabled) { cleanupRef.current(); @@ -92,9 +98,19 @@ export function useViewTracking(opts: UseViewTrackingOpts) { video === attachedVideoRef.current && recordingId === attachedRecordingIdRef.current && !!trackOpenWithoutVideo === attachedTrackOpenRef.current; - if (unchanged) return; + if (unchanged) { + // Still the same session — keep the duration in sync so an + // async-loaded duration is reflected without a full reattach. Do + // this before any potential teardown below so a genuine session + // change never mutates the ref before the old session's final flush. + durationMsRef.current = durationMs; + return; + } + // Tear down the previous session's listeners while durationMsRef still + // holds its duration, so its final flush computes completion correctly. cleanupRef.current(); + durationMsRef.current = durationMs; hasAttachedRef.current = true; attachedVideoRef.current = video; attachedRecordingIdRef.current = recordingId; @@ -155,9 +171,10 @@ export function useViewTracking(opts: UseViewTrackingOpts) { | "reaction", extra?: Record, ) { - const { videoRef, durationMs } = optsRef.current; + const { videoRef } = optsRef.current; const v = videoRef.current; if (!v) return; + const durationMs = durationMsRef.current; const completedPct = durationMs > 0 ? (watchMsRef.current / durationMs) * 100 : 0; maxPctRef.current = Math.max( @@ -248,11 +265,14 @@ export function useViewTracking(opts: UseViewTrackingOpts) { cleanupRef.current = () => {}; // Reset attachment identity so a StrictMode dev remount (or any real // remount that reuses the same video/recording) re-attaches instead - // of seeing "unchanged" and silently skipping setup. + // of seeing "unchanged" and silently skipping setup. Also reset the + // iframe-open dedup guard so a later reopen of the same no-video + // (e.g. Loom-backed) recording fires its view-start again. hasAttachedRef.current = false; attachedVideoRef.current = null; attachedRecordingIdRef.current = null; attachedTrackOpenRef.current = false; + openTrackedRecordingRef.current = null; }; }, []); From cb3d008e05da6b776eb1a01659bf68f9466caa3d Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 22 Jul 2026 23:49:43 +0000 Subject: [PATCH 6/6] refactor(clips): redesign view tracking to depend on the actual video element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback: the previous fix layered manual attachment-identity bookkeeping (hasAttachedRef/attachedVideoRef/attachedRecordingIdRef/ attachedTrackOpenRef) onto useViewTracking to work around an unstable videoRef object, but still had an unresolved bug — post() read videoRef.current live during a session's cleanup, which could already point at a different video (or null) by the time that cleanup ran. - VideoPlayer now accepts onVideoElementChange, wired to the real