feat: detect SPA page changes for AutoLogPageView - #1308
Conversation
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.
| private hashChangeListener: (() => void) | null = null; | ||
|
|
||
| constructor(mpInstance: IMParticleWebSDKInstance) { | ||
| this.mpInstance = mpInstance; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| // (throttled in background tabs) and queueMicrotask (may run before the | ||
| // render commit, yielding a stale title). | ||
| setTimeout(() => { | ||
| if (!this.isActive) { |
There was a problem hiding this comment.
Nit: the check for isActive should probably be higher up right when the navigation is happening as opposed to after all these checks.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
| window.history.pushState = function( | ||
| this: History, | ||
| ...args: Parameters<HistoryStateMethod> | ||
| ): void { | ||
| const result = self.originalPushState!.apply(this, args); | ||
| self.handleNavigation('pushState'); | ||
| return result; | ||
| }; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
This is a good callout actually and I'll see if we can wire in session logic just so we can respect that.
There was a problem hiding this comment.
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.
| // 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( |
There was a problem hiding this comment.
Using a Proxy might be a cleaner approach here, it retains all the metadata and makes the monkey patch harder to detect.
There was a problem hiding this comment.
This may be an issue if it's overridden by several scripts, not sure if there could be conflicts here
There was a problem hiding this comment.
Proxy might not work without a polyfill since we transpile down to ES5. I think it's something worth discussing as a fast follow.
| path: candidatePath, | ||
| title: window.document.title, | ||
| }); | ||
| this.mpInstance._Events.logPageView(); |
There was a problem hiding this comment.
Can we also add title and <link rel="canonical"> as data we capture? We can use these calibrate the mechanism and improve it later
There was a problem hiding this comment.
I think this would need to happen on the kit side. I'll flag that as a follow up task.
There was a problem hiding this comment.
@mattbodle Addressed in this kit pr: mparticle-integrations/mparticle-javascript-integration-rokt#112
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.
rmi22186
left a comment
There was a problem hiding this comment.
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.
| private handleNavigation(source: string): void { | ||
| const { pathname, search, hash } = window.location; | ||
| const candidatePath = pathname + search + hash; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
For this first pass, we want to intentionally avoid dealign with query params and hashes. We'll do this as a follow up.
| const { pathname, search, hash } = window.location; | ||
| this.lastPath = pathname + search + hash; |
There was a problem hiding this comment.
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.)
PR SummaryMedium Risk Overview The tracker hooks Adds Reviewed by Cursor Bugbot for commit 540838c. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ 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.
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.
|




What
Adds a
PageViewTrackerthat auto-logs a page view on client-side (SPA) navigations when theAutoLogPageViewfeature 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
history.pushState/history.replaceState(wrapping viaoriginal.apply), marking its wrapper so it never double-wrapspopstate(back/forward) andhashchange(hash routers)pathname + search + hash) — an identical URL is skipped; query- and hash-only changes firesetTimeout(fn, 0)sodocument.titlehas settled, resets the session timer, then calls_Events.logPageView()init()tears down internally first;teardown()removes listeners and restores original history methods only if the wrapper is still oursWired 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..nvmrcpins the Node version for local builds.