fix(plugin-autocapture-browser): report exposed elements on SPA page views - #1936
Draft
jxiwang wants to merge 5 commits into
Draft
fix(plugin-autocapture-browser): report exposed elements on SPA page views#1936jxiwang wants to merge 5 commits into
jxiwang wants to merge 5 commits into
Conversation
…cker on page end The scroll tracker is zeroed at the end of a page view but lastScroll kept the previous page view's maxima, so the guard that suppresses no-op events compared a fresh scroll state against stale values. On SPA pages this emitted Viewport Content Updated events with an empty Element Exposed array.
…fter an exposure reset An IntersectionObserver only reports threshold crossings, so resetting the exposure state at the end of a page view meant nothing already in the viewport was ever reported again. On SPA sites, where a page end is triggered by a history update rather than an unload, this left Element Exposed empty for all above-the-fold content until the user scrolled it out of view and back.
…actually changes The Navigation API emits navigate for same-document history updates, including the history rewrites SPA frameworks do on hydration. Each one flushed a premature Viewport Content Updated event and reset the exposure state, so the page view that followed reported nothing. Page ends are now keyed off a URL change, pagehide is handled alongside beforeunload for browsers where beforeunload is unreliable, and the dedupe flag is only set for page ends so a buffer flush can no longer swallow the final event.
…e body does not exist yet When autocapture is initialized from the document head the mutation observer had nothing to attach to and was silently skipped, so no element added afterwards was ever observed for the rest of the page.
size-limit report 📦
|
…in the scroll maxima The scroll tracker only learned from scroll events it witnessed, so landing on a URL with a fragment reported Max Page Y as one viewport: the browser jumps to the anchor before the subscription exists. The maxima now fold in the current position when read, and a page end rebaselines on where the page actually is rather than assuming the top.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
On some pages
[Amplitude] Viewport Content Updatednever fires, and when it is forced it carries an empty[Amplitude] Element Exposedarray. Reproduced on https://www.clubmed.fr/ (Next.js App Router) by loading the built SDK on the live page and capturing events through an enrichment plugin:Four separate defects combine to produce that:
The Navigation API reports same-document history updates as navigations.
history.pushState/replaceStatefirenavigate, and SPA frameworks call them routinely — Next.js does it during hydration. Every one was treated as the end of a page view, flushing a premature event and resetting all exposure state. Page ends are now keyed off an actual URL change (usingNavigateEvent.destination.url, which is available before the URL updates).Resetting the exposure state did not re-arm the IntersectionObserver. An observer only reports threshold crossings, so after a reset nothing already in the viewport was ever reported again. Everything above the fold stayed unexposed until the user scrolled it out of view and back — which is why forcing the event returned an empty array. Elements in the viewport are now re-observed on reset so they are reported once for the new page view.
lastScrollwas not reset with the scroll tracker. The guard that suppresses no-op events compared a zeroed scroll state against the previous page view's maxima, so it both let empty events through and suppressed the event entirely on pages that do not scroll.The mutation observer was skipped when
document.bodydid not exist yet. Initializing autocapture from the document head left it attached to nothing, so no element added afterwards was ever observed. It now falls back to the document element.Also in this PR:
pagehideis handled alongsidebeforeunload, sincebeforeunloaddoes not fire reliably on mobile browsers or when a page enters the back/forward cache, and the page-end dedupe flag is only set for page ends so that a buffer flush can no longer swallow the final event.After the fix, the same live-page scenario reports the on-screen elements at every page end:
Scroll maxima and URL fragments
Reviewing the page-end reset against anchor navigation surfaced a related bug in
trackScroll, fixed here too. The tracker only learned from scroll events it witnessed and started from zero, so landing directly on a URL with a fragment reported a single viewport of depth — the browser jumps to the anchor before the plugin's scroll subscription exists. The maxima now fold in the current position when read, and a page end rebaselines on where the page actually is instead of assuming the top, which also covers SPA route changes that leave the scroll position where it was.Measured on a synthetic 6000px page with anchors,
Max Page Yfor a viewport of 800 at offset 3106:/page#section-3#section-3(page view before the jump)#section-3(page view after the jump)/page, stay at the topKnown limitation, unchanged
The exposure observer uses
threshold: 1.0, so an allowlisted element larger than the viewport, or clipped by a scroll container, is never counted as exposed. On the clubmed.fr homepage that accounts for 2 of 22 in-viewport elements. Changing it would change event volume and semantics, so it is left alone.Testing
packages/plugin-autocapture-browserkeeps 100% coverage. Unit tests cover the re-observe path, the URL-change guard for both the Navigation API and the popstate/pushState fallback,pagehideand its deduplication withbeforeunload, the scroll rebaseline and the fragment-jump position, and the document element fallback.pnpm build,pnpm test,pnpm lint,pnpm lint:depsandpnpm docs:checkpass. (pnpm test:examplesfails onmaintoo — itsjest.setup.examples.jsis missing from the repo.)Checklist
pushState/replaceStatewithout changing the URL will stop emitting a Viewport Content Updated event per call, which is the bug being fixed.