Skip to content

fix(plugin-autocapture-browser): report exposed elements on SPA page views - #1936

Draft
jxiwang wants to merge 5 commits into
mainfrom
cursor/fix-viewport-content-updated-exposure-3a17
Draft

fix(plugin-autocapture-browser): report exposed elements on SPA page views#1936
jxiwang wants to merge 5 commits into
mainfrom
cursor/fix-viewport-content-updated-exposure-3a17

Conversation

@jxiwang

@jxiwang jxiwang commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

On some pages [Amplitude] Viewport Content Updated never fires, and when it is forced it carries an empty [Amplitude] Element Exposed array. 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:

fully-visible allowlisted elements in viewport: 20
after first page-end:                           exposed: 17
after 3s dwell, no scroll:                      (no event at all)
after pushState + 2.5s dwell, no scroll:        (no event at all)
after scrolling away and back to top:           exposed: 47

Four separate defects combine to produce that:

  1. The Navigation API reports same-document history updates as navigations. history.pushState/replaceState fire navigate, 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 (using NavigateEvent.destination.url, which is available before the URL updates).

  2. 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.

  3. lastScroll was 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.

  4. The mutation observer was skipped when document.body did 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: pagehide is handled alongside beforeunload, since beforeunload does 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:

after first page-end:                           exposed: 17
after 3s dwell, no scroll:                      exposed: 17
after pushState + 2.5s dwell, no scroll:        exposed: 17
after scrolling away and back to top:           exposed: 47

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 Y for a viewport of 800 at offset 3106:

scenario before after
land on /page#section-3 800 3906
click in-page anchor to #section-3 (page view before the jump) 800 800
click in-page anchor to #section-3 (page view after the jump) 3906 3906
land on /page, stay at the top 800 800

Known 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-browser keeps 100% coverage. Unit tests cover the re-observe path, the URL-change guard for both the Navigation API and the popstate/pushState fallback, pagehide and its deduplication with beforeunload, the scroll rebaseline and the fragment-jump position, and the document element fallback. pnpm build, pnpm test, pnpm lint, pnpm lint:deps and pnpm docs:check pass. (pnpm test:examples fails on main too — its jest.setup.examples.js is missing from the repo.)

Checklist

  • Does your PR title have the correct title format?
  • Does your PR have a breaking change?: No. Pages that call pushState/replaceState without changing the URL will stop emitting a Viewport Content Updated event per call, which is the bug being fixed.
Open in Web Open in Cursor 

…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.
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
packages/analytics-browser/lib/scripts/amplitude-min.js.gz 61.7 KB (+0.31% 🔺)
packages/session-replay-browser/lib/scripts/session-replay-browser-min.js.gz 134.97 KB (0%)
packages/unified/lib/scripts/amplitude-min.umd.js.gz 215.78 KB (+0.09% 🔺)
@amplitude/element-selector (gzipped esm) 2.67 KB (0%)

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants