Skip to content

feat: detect SPA page changes for AutoLogPageView - #1308

Open
alexs-mparticle wants to merge 5 commits into
developmentfrom
feat/auto-log-page-view-spa
Open

feat: detect SPA page changes for AutoLogPageView#1308
alexs-mparticle wants to merge 5 commits into
developmentfrom
feat/auto-log-page-view-spa

Conversation

@alexs-mparticle

@alexs-mparticle alexs-mparticle commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a PageViewTracker that auto-logs a page view on client-side (SPA) navigations when the AutoLogPageView feature flag is enabled. Previously the flag only fired a single page view during SDK init (the MPA model); SPAs navigate via the History API without a reload, so no further page views fired.

How

  • Monkey-patches history.pushState / history.replaceState (wrapping via original.apply), marking its wrapper so it never double-wraps
  • Listens for popstate (back/forward) and hashchange (hash routers)
  • Dedupes on the full path (pathname + search + hash) — an identical URL is skipped; query- and hash-only changes fire
  • Defers the fire via setTimeout(fn, 0) so document.title has settled, resets the session timer, then calls _Events.logPageView()
  • Idempotent: init() tears down internally first; teardown() removes listeners and restores original history methods only if the wrapper is still ours
  • Navigation handling is wrapped so a throw skips that page view without breaking the host page

Wired into completeSDKInitialization: when the flag is on, fire the initial landing page view and start the tracker; if the flag is off on a re-init, tear the tracker down.

Detection stages log through mpInstance.Logger.verbose, respecting the SDK's configured log level.

Testing

Adds test/jest/pageViewTracker.spec.ts (24 cases) covering the double-wrap guard, navigation detection, dedupe, deferred fire, session-timer reset, error isolation, and teardown. .nvmrc pins the Node version for local builds.

Add PageViewTracker to auto-log a page view on client-side (SPA)
navigations when the AutoLogPageView feature flag is enabled. The tracker
monkey-patches history.pushState/replaceState and listens for
popstate/hashchange, deduping by pathname and firing the deferred
_Events.logPageView() so the document title has settled.

Wired into completeSDKInitialization: constructs and inits the tracker
when the flag is on, and tears it down if the flag is off on re-init.

This is a validation POC: each detection stage emits a
console.warn('Rokt APV:', ...) so the logic can be verified locally via
browser overrides. The debug logging is not intended to ship.
Comment thread src/pageViewTracker.ts
private hashChangeListener: (() => void) | null = null;

constructor(mpInstance: IMParticleWebSDKInstance) {
this.mpInstance = mpInstance;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not opposed to this, but I know you've given feedback about not putting the entire mPInstance into the constructor...the bot probably followed most of the examples of how classes are instantiated here and went that route. Just flagging

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor. in a case where there are multiple instances, will this work? We only have 1 customer that i'm aware of and they are legacy CDP

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is temporary so I'm not worried about it at the moment. I think the only thing we'll need to inject is the _Events module to fire actual events, so this will likely go away.

Comment thread src/pageViewTracker.ts Outdated
Comment thread src/pageViewTracker.ts Outdated
Comment thread src/pageViewTracker.ts Outdated
Comment thread src/pageViewTracker.ts Outdated
Comment thread src/pageViewTracker.ts Outdated
Comment thread src/pageViewTracker.ts
// (throttled in background tabs) and queueMicrotask (may run before the
// render commit, yielding a stale title).
setTimeout(() => {
if (!this.isActive) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the check for isActive should probably be higher up right when the navigation is happening as opposed to after all these checks.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if I follow your reasoning. This is a set timeout that runs after a navigation event so we just need to check if the pagetracker is still running. The guard is likely unnecessary since we can't turn it off in runtime but I think it's a good safety check.

@rmi22186 rmi22186 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the POC against the design doc. The detection architecture is right and matches the plan, but there are two bugs in the teardown/wrapper path (C1, C2) and one silent data-loss issue around session expiry (C3) — inline comments below. Details and suggested fixes in each comment.

Comment thread src/pageViewTracker.ts Outdated
Comment thread src/pageViewTracker.ts Outdated
Comment on lines +109 to +116
window.history.pushState = function(
this: History,
...args: Parameters<HistoryStateMethod>
): void {
const result = self.originalPushState!.apply(this, args);
self.handleNavigation('pushState');
return result;
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C2: nulling the originals while the wrapper may stay installed will throw on the customer's next navigation.

In teardown's "wrapper no longer ours" branch, our wrapper stays in the chain (the third party's wrapper still calls it) but this.originalPushState is set to null (L222–223). The next router navigation then runs self.originalPushState!.apply(this, args)TypeError: Cannot read properties of null → the customer's router breaks. The non-null assertion is masking a genuinely reachable null, and once C1 is fixed this branch becomes the common path whenever another analytics SDK is present.

Structural fix: capture the original in a closure-local const so the wrapper never reads mutable instance state — then it can never crash regardless of who patched what:

const originalPushState = window.history.pushState;
const wrapper = function(this: History, ...args: Parameters<HistoryStateMethod>) {
    const result = originalPushState.apply(this, args);
    self.handleNavigation('pushState');
    return result;
};
window.history.pushState = wrapper;
this.pushStateWrapper = wrapper; // for the teardown identity check (see C1)

(Same for replaceState.) Design Q9's must-cover case 6 (teardown while another wrapper sits on top) would have caught both C1 and C2 — worth writing that spec first when productionizing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 63f784e. The wrapper now closes over a local const instead of reading mutable instance state, so it can never hit a null this.originalPushState regardless of who patched over us:

const originalPushState = window.history.pushState;
this.originalPushState = originalPushState;

const pushStateWrapper = function(this: History, ...args) {
    const result = originalPushState.apply(this, args);
    self.safeHandleNavigation('pushState');
    return result;
};
this.pushStateWrapper = pushStateWrapper;

The non-null assertion is gone. Same treatment for replaceState.

Comment thread src/pageViewTracker.ts
Comment on lines +171 to +185
setTimeout(() => {
if (!this.isActive) {
// eslint-disable-next-line no-console
console.warn(
'Rokt APV: [defer] fire aborted, tracker inactive (torn down before flush)'
);
return;
}
// eslint-disable-next-line no-console
console.warn('Rokt APV: [fire] deferred flush -> _Events.logPageView()', {
path: candidatePath,
title: window.document.title,
});
this.mpInstance._Events.logPageView();
}, 0);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C3: session expiry silently drops SPA page views — open question Q2 must be resolved as Opt 2.

When the session times out, performSessionEnd()nullifySession() clears _Store.sessionId, and serverModel.createEventObject returns null when there's no session (serverModel.ts:272–277, :389). So a user who idles past the 30-minute timeout and then navigates gets a page view that is dropped entirely, not just misattributed. That's a core SPA pattern (tab left open, user returns).

Fix: call this.mpInstance._SessionManager.startNewSessionIfNeeded() before _Events.logPageView() in this deferred callback.

Note the POC's console-warn validation structurally cannot surface this: [fire] logs before logEvent, which then drops the event downstream — the console looks healthy while data goes missing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good callout actually and I'll see if we can wire in session logic just so we can respect that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the POC — you're right that it went straight to logPageView() with no session handling, so an idled-out session would've dropped the page view. This is wired up now (the deferred flush calls resetSessionTimer() before logPageView()), which actually covers a bit more than startNewSessionIfNeeded() alone:

this.resetSessionTimer = function () {
    if (!mpInstance._Store.webviewBridgeEnabled) {
        if (!mpInstance._Store.sessionId) {
            self.startNewSession();      // recreates the nullified session
        }
        self.clearSessionTimeout();
        self.setSessionTimer();          // also resets the timeout clock
    }
    self.startNewSessionIfNeeded();      // your suggested call
};

I confirmed the drop you described: after performSessionEnd() -> nullifySession(), createEventObject returns null because _Store.sessionId is falsy (serverModel.ts:273-277, :388). startNewSession() sets sessionId synchronously (sessionManager.ts:96) before logPageView() reads it, so the returning-user navigation now starts a fresh session — matching what a full page reload would do — instead of dropping the event.

Comment thread src/pageViewTracker.ts Outdated
// untouched, then handle the navigation. Patch both identically —
// path-changing replaceState (redirects) should also log a view;
// under-counting is worse than a rare extra view.
window.history.pushState = function(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a Proxy might be a cleaner approach here, it retains all the metadata and makes the monkey patch harder to detect.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be an issue if it's overridden by several scripts, not sure if there could be conflicts here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proxy might not work without a polyfill since we transpile down to ES5. I think it's something worth discussing as a fast follow.

Comment thread src/pageViewTracker.ts Outdated
path: candidatePath,
title: window.document.title,
});
this.mpInstance._Events.logPageView();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also add title and <link rel="canonical"> as data we capture? We can use these calibrate the mechanism and improve it later

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would need to happen on the kit side. I'll flag that as a follow up task.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace console.warn debug calls in PageViewTracker with
mpInstance.Logger.verbose so SPA page-view tracking respects the
SDK log level. Adds jest coverage and .nvmrc.
@alexs-mparticle alexs-mparticle changed the title feat: detect SPA page changes for AutoLogPageView (POC) feat: detect SPA page changes for AutoLogPageView Aug 6, 2026

@rmi22186 rmi22186 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up on the dedup key change in 63f784e. C1/C2/C3 all look correctly addressed — the closure-captured originals, the wrapper-identity check, and the session renewal before the deferred fire are exactly right, and the new Jest spec covers the foreign-wrapper teardown case. One concern on the dedup key, inline.

Comment thread src/pageViewTracker.ts Outdated
Comment on lines +164 to +166
private handleNavigation(source: string): void {
const { pathname, search, hash } = window.location;
const candidatePath = pathname + search + hash;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dedup key: including search will over-count page views, and now also keeps sessions alive.

This reverses design decision Q3(a) (pathname-only, "stricter than GA4… reduces noise"), and the design doc and PR description still describe the old behavior. Adding hash is necessary — it fixes hash routers, which were previously dead (a hash-routed SPA never changes pathname, so the hashchange listener could never fire a view). But search goes further than needed.

Apps routinely write transient UI state into the query string via replaceState — filters, sort order, pagination, active tab, search-as-you-type:

/products
/products?sort=price
/products?sort=price&color=red
/products?sort=price&color=red&page=2

That's one user on one page, but four distinct keys, so four page views. The inflation factor varies per customer depending purely on how their router is written, so it isn't correctable downstream.

Compounding it: the C3 fix means each fire calls resetSessionTimer(), which re-arms the 30-minute inactivity clock. An app that updates the query string from background activity (a polling dashboard, a refresh token in the URL) would extend the session indefinitely with no user present.

Suggested change — key on pathname + hash, drop search:

const { pathname, hash } = window.location;
const candidatePath = pathname + hash;

(The seed at L59–60 in init() has to change identically or the first navigation mis-fires.)

Caveat worth deciding explicitly: the hash isn't only used by routers — plain anchor links (<a href="#reviews">, docs tables of contents) also fire hashchange and change location.hash. So this trades query-string noise for anchor-link noise. Anchor clicks and hash-route changes go through the identical primitive, so filtering by signal source can't separate them. If anchor noise proves material, the usual heuristic is to count only route-shaped hashes:

const routeHash = hash.startsWith('#/') ? hash : '';
const candidatePath = pathname + routeHash;

That covers Vue Router's createWebHashHistory, Angular's HashLocationStrategy, and legacy React Router (all produce #/settings), while ignoring bare #reviews. It is a heuristic — a hash router configured without the leading slash would be missed.

My suggestion: ship pathname + hash, document the anchor-link trade-off, and keep the #/ refinement in reserve pending real-world data. Whichever way this lands, Q3(a) in the design doc needs rewriting — it currently says the opposite of what the code does.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this first pass, we want to intentionally avoid dealign with query params and hashes. We'll do this as a follow up.

Comment thread src/pageViewTracker.ts Outdated
Comment on lines +59 to +60
const { pathname, search, hash } = window.location;
this.lastPath = pathname + search + hash;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seed and the comparison in handleNavigation (L165–166) have to use an identical key — if they drift, the first navigation after init either mis-fires or is wrongly suppressed. Worth a brief comment tying them together, or extracting a small private getCurrentKey() used by both, so a future edit to one can't silently desync the other. (See the dedup-key comment on L164–166 for the substantive change I'd suggest here.)

@alexs-mparticle
alexs-mparticle marked this pull request as ready for review August 7, 2026 02:19
@alexs-mparticle
alexs-mparticle requested a review from a team as a code owner August 7, 2026 02:19
@cursor

cursor Bot commented Aug 7, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Global history monkey-patching and analytics/session side effects can interact with host routers and third-party wrappers, though guards and teardown limit blast radius.

Overview
When AutoLogPageView is enabled, the SDK now keeps logging page views after the initial load by starting a new PageViewTracker during completeSDKInitialization (and tearing it down if the flag is off on re-init).

The tracker hooks history.pushState / replaceState and popstate, dedupes on pathname (query/hash-only URL changes do not fire), defers each fire with setTimeout(0) so document.title can update, snapshots the path per navigation for rapid same-tick routes, resets the session timer, and logs PageView via _Events.logEvent. It avoids double-wrapping history, rolls back failed patches, and restores listeners/methods safely on teardown.

Adds test/jest/pageViewTracker.spec.ts and pins Node v24.16.0 in .nvmrc.

Reviewed by Cursor Bugbot for commit 540838c. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 63f784e. Configure here.

Comment thread src/pageViewTracker.ts
Comment thread src/pageViewTracker.ts
Bug 1 (teardown asymmetry): evaluate pushState and replaceState restoration
independently by identity, so a still-ours replaceState wrapper is restored
even when a third party has replaced only pushState. Prevents a leaked wrapper
from being double-wrapped on re-init.

Bug 2 (final-URL capture): snapshot the settled path at accept time and pass it
through to the deferred flush, so each fire reports its own navigation rather
than reading the live location (which a same-tick navigation would overwrite).
Extract the fire logic into a testable firePageView(path) that emits via
_Events.logEvent(PageView) carrying path, title, and hostname.
The init() seed and handleNavigation() comparison derived the dedup key by
hand in two places; a future edit to one could silently desync the other,
mis-firing or suppressing the first navigation after init. Extract a single
private getCurrentKey() used by both.
Including search in the dedup key over-counted page views (transient UI
state written to the query string via replaceState produced a distinct key
per change) and, combined with the per-fire resetSessionTimer() call, could
keep a session alive indefinitely from background query-string churn with no
user present.

Key and report pathname only. Hash-router support is deferred to a follow-up
along with the anchor-link-vs-route-hash question; the hashchange listener is
removed until then.
@sonarqubecloud

sonarqubecloud Bot commented Aug 7, 2026

Copy link
Copy Markdown

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.

3 participants