Fix view tracking video ref to prevent stale references#2318
Fix view tracking video ref to prevent stale references#2318builder-io-integration[bot] wants to merge 9 commits into
Conversation
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Visual recap — skippedThe visual recap job did not run for this pull request. This is informational only and does not block the PR. Recap skipped for |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
@builderio-bot look at latest PR feedback and fix anything you agree with. Be skeptical. Reply to every comment (directly on the comment thread of each comment) if you fixed it or not and why. then check back every 2 minutes on a loop and see if any new feedback posted, until at least 20 minutes go by without any new feedback posted we want to address, including making sure CI passes too and no merge conflicts (make sure code is mergeable) |
…536c590ae6f9434e82e6
…hanges 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.
… 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.
This comment has been minimized.
This comment has been minimized.
|
There was a problem with your request, please try again later. Error id: |
|
@builderio-bot look at latest PR feedback and fix anything you agree with. Be skeptical. Reply to every comment (directly on the comment thread of each comment) if you fixed it or not and why. then check back every 2 minutes on a loop and see if any new feedback posted, until at least 20 minutes go by without any new feedback posted we want to address, including making sure CI passes too and no merge conflicts (make sure code is mergeable) |
…536c590ae6f9434e82e6
…ndling - 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.
…n 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.
|
There was a problem with your request, please try again later. Error id: |
There was a problem hiding this comment.
Builder reviewed your changes and found 1 potential issue 🟡
Review Details
Incremental Code Review Summary
The latest commit fixes both previously reported issues: iframe-open deduplication is cleared on unmount, and durationMsRef is updated only after the previous session’s cleanup so final completion percentages use the correct denominator. Those prior review threads were resolved before this review. The remaining change is standard risk because it manages shared listener/session state across Clips recording, share, and embed routes.
New Finding
- 🟡 Final flush can read the next session’s video element — while the old session’s recording ID and duration are now preserved,
post()still resolvesvideoRef.currentfrom the live options ref. During a video/recording transition, cleanup can therefore derive timestamp and end-state from the newly mounted video, or drop the old session’s final flush if the new player is temporarily absent. Capture the attached video element for each session’s cleanup/post path.
The prior remount, recording identity, duration, StrictMode, and iframe-open lifecycle fixes otherwise look correct. Browser verification was attempted, but executor sessions lacked Chrome automation tools and authenticated owner routes lacked test credentials; the dev server was healthy.
| const { videoRef } = optsRef.current; | ||
| const v = videoRef.current; |
There was a problem hiding this comment.
🟡 Use the attached video for the previous session flush
post() now uses the correct captured recording ID and session-scoped duration, but it still resolves videoRef.current from optsRef.current. When cleanup runs during a video/recording transition, that live ref can point to the new video (changing timestampMs/scrubbedToEnd) or be null while the old session still needs its final flush. Capture the video element when attaching the session and use that captured element for cleanup/post events.
Additional Info
Identified by incremental code review and independently confirmed by the browser-test planner's static trace. Live browser verification was blocked by missing Chrome automation tools and unavailable authenticated test credentials.
There was a problem hiding this comment.
This looks like it was generated against an earlier commit (ff4534f95) — the redesign pushed right after in cb3d008e0 removed optsRef/videoRef entirely from this file.
Current code: post() reads video.currentTime/video.duration from const video = videoEl (line 132), a plain closure variable captured once per effect invocation — not a live ref lookup. Since the effect now depends on [recordingId, videoEl, trackOpenWithoutVideo, disabled], a session transition creates an entirely new closure with its own video, and the outgoing session's cleanup still closes over its own original video/recordingId, so there's no way for it to read a different session's element. grep -n \"optsRef\\|videoRef\" use-view-tracking.ts on the current HEAD returns zero matches.
See the full context in my PR comment on this redesign and the accompanying use-view-tracking.test.tsx (6 lifecycle tests, all passing) if useful.
|
@builderio-bot My gut check: yes, we should fix the underlying problem—but I would not merge [PR #2318](#2318) as it stands. The bug is real, though the original explanation is imprecise. The inline
My preferred fix would expose the actual I’d require tests covering rerenders while playing, video replacement/Edit → Done, recording changes, async Loom activation, unmount/final flush, and StrictMode. So the non-technical person surfaced a legitimate issue. The technical solution just isn’t ready or complete yet. |
…536c590ae6f9434e82e6
… element 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 <video> ref via a callback ref, so callers can hold the actual HTMLVideoElement in state instead of polling an imperative-handle getter. - useViewTracking now takes that element directly (videoEl) and depends on [recordingId, videoEl, trackOpenWithoutVideo, disabled] in a normal useEffect. React's own dependency comparison decides when to reattach; each closure captures its own video/recordingId, so a cleanup's final flush always describes the session it belonged to. durationMs stays read live from a ref since it can load asynchronously without needing a reattach. - r.$recordingId.tsx, share.$shareId.tsx, embed.$shareId.tsx: hold the video element in useState instead of the unstable inline ref-getter object. embed.$shareId.tsx had the identical bug and was previously unaddressed by this PR. - Added use-view-tracking.test.tsx covering: rerenders while playing, video replacement (Edit -> Done), recordingId changes on a reused element, async Loom trackOpenWithoutVideo activation, unmount/final- flush attribution, and StrictMode's dev mount/cleanup/remount cycle.
|
Agreed on every point — this was the right call not to merge as-is, and I redesigned it instead of patching further. The The lifecycle state machine (real complexity problem): agreed,
Tests (real gap): added
Full template test suite (695 tests, including the existing PR description: rewritten to describe the actual current diff instead of the original memoization-only framing. Pushed as |
|
There was a problem with your request, please try again later. Error id: |
Summary
Fixes anonymous/all-viewer view-count undercounting on Clips share pages by giving
useViewTrackinga correct dependency on the actual<video>element lifecycle, instead of a hand-rolled ref-identity workaround. Also fixes the same bug onembed.$shareId.tsx, which the original fix missed, and adds lifecycle tests.Problem
useViewTrackingreceived an inlinevideoRefgetter-object recreated on every render ofr.$recordingId.tsx/share.$shareId.tsx/embed.$shareId.tsx. Because that object was in the effect's dependency array, the tracking effect tore down and re-ran on every unrelated parent render. Since the video was already playing, no new nativeplayevent fired to restart the heartbeat interval that accumulates watch time — so watch time silently stayed near zero and views never crossed the 5s/75% counted-view threshold, for every viewer (anonymous and logged-in alike).An initial fix memoized the ref object and layered manual "has this identity changed" bookkeeping into the hook to decide when to reattach. That approach grew fragile across several follow-up commits (StrictMode remounts, cross-session
recordingId/durationMsstaleness during cleanup, iframe-open dedup surviving unmount) and, as flagged in review, still had an unresolved bug:post()readvideoRef.currentlive during a session's cleanup, which could already point at a different video (ornull) by the time that cleanup ran.Solution
Redesigned per review feedback:
VideoPlayernow accepts anonVideoElementChangecallback wired to the real<video>ref, so callers can hold the actualHTMLVideoElementin React state.useViewTrackingnow takes that element directly (videoEl) and depends on[recordingId, videoEl, trackOpenWithoutVideo, disabled]in a normaluseEffect— React's own dependency comparison decides when to reattach, and each effect closure captures its ownvideo/recordingId, so a cleanup's final flush always describes the session it belonged to.durationMsis deliberately excluded from the deps (it can load asynchronously without needing a reattach) and is read through a ref kept in sync every render.Key Changes
hooks/use-view-tracking.ts: rewritten to acceptvideoEl: HTMLVideoElement | nulland use a standard effect dependency array instead of manual attachment-identity refs.components/player/video-player.tsx: addedonVideoElementChangeprop, wired via a callback ref alongside the existing internalvideoRef.routes/r.$recordingId.tsx,routes/share.$shareId.tsx,routes/embed.$shareId.tsx: now hold the video element inuseStateand pass it touseViewTracking, instead of the unstable inline ref-getter object.embed.$shareId.tsxhad the identical bug and was previously unaddressed.hooks/use-view-tracking.test.tsx(new): covers rerenders while playing, video replacement (Edit → Done),recordingIdchanges on a reused element, async LoomtrackOpenWithoutVideoactivation, unmount/final-flush attribution, and React StrictMode's dev mount/cleanup/remount cycle.This PR was created by Tim Milazzo (tmilazzo@builder.io)
To clone this PR locally use the Github CLI with command
gh pr checkout 2318You can tag me at @BuilderIO for anything you want me to fix or change