-
Notifications
You must be signed in to change notification settings - Fork 59
feat: detect SPA page changes for AutoLogPageView #1308
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ea86816
63f784e
39a5594
b7445f0
540838c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| v24.16.0 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,267 @@ | ||
| import { IMParticleWebSDKInstance } from './mp-instance'; | ||
| import { EventType, MessageType } from './types'; | ||
|
|
||
| type HistoryStateMethod = History['pushState']; | ||
|
|
||
| const WRAPPED_MARKER = '__mpApvWrapped__'; | ||
|
|
||
| type MarkedHistoryMethod = HistoryStateMethod & { | ||
| [WRAPPED_MARKER]?: boolean; | ||
| }; | ||
|
|
||
| export class PageViewTracker { | ||
| mpInstance: IMParticleWebSDKInstance; | ||
|
|
||
| private lastPath: string | null = null; | ||
| private isActive = false; | ||
|
|
||
| private originalPushState: HistoryStateMethod | null = null; | ||
| private originalReplaceState: HistoryStateMethod | null = null; | ||
|
|
||
| private pushStateWrapper: HistoryStateMethod | null = null; | ||
| private replaceStateWrapper: HistoryStateMethod | null = null; | ||
|
|
||
| private popStateListener: (() => void) | null = null; | ||
|
|
||
| constructor(mpInstance: IMParticleWebSDKInstance) { | ||
| this.mpInstance = mpInstance; | ||
| } | ||
|
|
||
| private isSupportedEnvironment(): boolean { | ||
| return ( | ||
| typeof window !== 'undefined' && | ||
| typeof window.history !== 'undefined' && | ||
|
Check warning on line 33 in src/pageViewTracker.ts
|
||
| typeof window.history.pushState === 'function' && | ||
| typeof window.addEventListener === 'function' | ||
| ); | ||
| } | ||
|
|
||
| public init(): void { | ||
| this.mpInstance.Logger.verbose( | ||
| 'mParticle APV: [init] PageViewTracker Init' | ||
| ); | ||
| if (!this.isSupportedEnvironment()) { | ||
| this.mpInstance.Logger.verbose( | ||
| 'mParticle APV: [init] unsupported environment (no History API), not starting' | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| if (this.isActive) { | ||
| this.mpInstance.Logger.verbose( | ||
| 'mParticle APV: [init] starting (teardown-first for idempotency)' | ||
| ); | ||
| this.teardown(); | ||
| } | ||
|
|
||
| this.isActive = true; | ||
|
|
||
| this.lastPath = this.getCurrentKey(); | ||
|
|
||
| this.mpInstance.Logger.verbose( | ||
| `mParticle APV: [init] seeded lastPath: ${this.lastPath}` | ||
| ); | ||
|
|
||
| this.patchHistoryMethods(); | ||
| this.addNavigationListeners(); | ||
|
|
||
| this.mpInstance.Logger.verbose( | ||
| 'mParticle APV: [init] patched pushState/replaceState + listening for popstate' | ||
| ); | ||
| } | ||
|
|
||
| private patchHistoryMethods(): void { | ||
| const self = this; | ||
|
Check failure on line 74 in src/pageViewTracker.ts
|
||
|
|
||
| const installed = window.history.pushState as MarkedHistoryMethod; | ||
| if (installed[WRAPPED_MARKER]) { | ||
| this.mpInstance.Logger.verbose( | ||
| 'mParticle APV: [patch] history already wrapped, skipping to avoid double-wrap' | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| const originalPushState = window.history.pushState; | ||
| const originalReplaceState = window.history.replaceState; | ||
| this.originalPushState = originalPushState; | ||
| this.originalReplaceState = originalReplaceState; | ||
|
|
||
| const pushStateWrapper = function( | ||
| this: History, | ||
| ...args: Parameters<HistoryStateMethod> | ||
| ): void { | ||
| const result = originalPushState.apply(this, args); | ||
| self.safeHandleNavigation('pushState'); | ||
| return result; | ||
| }; | ||
|
|
||
| const replaceStateWrapper = function( | ||
| this: History, | ||
| ...args: Parameters<HistoryStateMethod> | ||
| ): void { | ||
| const result = originalReplaceState.apply(this, args); | ||
| self.safeHandleNavigation('replaceState'); | ||
| return result; | ||
| }; | ||
|
|
||
| Object.defineProperty(pushStateWrapper, WRAPPED_MARKER, { | ||
| value: true, | ||
| enumerable: false, | ||
| }); | ||
| Object.defineProperty(replaceStateWrapper, WRAPPED_MARKER, { | ||
| value: true, | ||
| enumerable: false, | ||
| }); | ||
|
|
||
| this.pushStateWrapper = pushStateWrapper; | ||
| this.replaceStateWrapper = replaceStateWrapper; | ||
|
|
||
| try { | ||
| window.history.pushState = pushStateWrapper; | ||
| window.history.replaceState = replaceStateWrapper; | ||
| } catch (e) { | ||
| this.mpInstance.Logger.verbose( | ||
| `mParticle APV: [error] failed to patch history methods (frozen/sealed), rolling back: ${e}` | ||
| ); | ||
| try { | ||
| if (window.history.pushState === pushStateWrapper) { | ||
| window.history.pushState = originalPushState; | ||
| } | ||
| if (window.history.replaceState === replaceStateWrapper) { | ||
| window.history.replaceState = originalReplaceState; | ||
| } | ||
| } catch (restoreError) { | ||
| this.mpInstance.Logger.verbose( | ||
| `mParticle APV: [error] failed to restore history methods after patch failure: ${restoreError}` | ||
| ); | ||
| } | ||
| this.pushStateWrapper = null; | ||
| this.replaceStateWrapper = null; | ||
| this.originalPushState = null; | ||
| this.originalReplaceState = null; | ||
| } | ||
| } | ||
|
|
||
| private addNavigationListeners(): void { | ||
| this.popStateListener = () => this.safeHandleNavigation('popstate'); | ||
| window.addEventListener('popstate', this.popStateListener); | ||
| } | ||
|
|
||
| private safeHandleNavigation(source: string): void { | ||
| try { | ||
| this.handleNavigation(source); | ||
| } catch (e) { | ||
| this.mpInstance.Logger.verbose( | ||
| `mParticle APV: [error] navigation handler threw (${source}), page view skipped: ${e}` | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| private getCurrentKey(): string { | ||
| return window.location.pathname; | ||
| } | ||
|
|
||
| private handleNavigation(source: string): void { | ||
| const candidatePath = this.getCurrentKey(); | ||
|
|
||
| this.mpInstance.Logger.verbose( | ||
| `mParticle APV: [detect] navigation signal (source: ${source}, candidatePath: ${candidatePath}, lastPath: ${this.lastPath})` | ||
| ); | ||
|
|
||
| if (candidatePath === this.lastPath) { | ||
| this.mpInstance.Logger.verbose( | ||
| `mParticle APV: [dedupe] pathname unchanged, skipping (source: ${source}, path: ${candidatePath})` | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| this.mpInstance.Logger.verbose( | ||
| `mParticle APV: [accept] pathname changed, scheduling fire (source: ${source}, from: ${this.lastPath}, to: ${candidatePath})` | ||
| ); | ||
| this.lastPath = candidatePath; | ||
|
|
||
| // Snapshot the path now: it is already settled at this point, and a | ||
| // same-tick navigation would otherwise overwrite window.location | ||
| // before the deferred flush reads it. The title is intentionally read | ||
| // later (in the deferred flush) so the router's post-navigation render | ||
| // commit has a chance to update document.title first. | ||
| const capturedPath = candidatePath; | ||
|
|
||
| setTimeout(() => { | ||
| if (!this.isActive) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| this.mpInstance.Logger.verbose( | ||
| 'mParticle APV: [defer] fire aborted, tracker inactive (torn down before flush)' | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| this.mpInstance._SessionManager.resetSessionTimer(); | ||
| this.firePageView(capturedPath); | ||
| }, 0); | ||
|
Comment on lines
+190
to
+200
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Fix: call Note the POC's console-warn validation structurally cannot surface this:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // Mirrors the event shape of the public mParticle.logPageView(), but carries | ||
| // the captured SPA path rather than reading the live location. | ||
| private firePageView(path: string): void { | ||
| const title = window.document.title; | ||
|
|
||
| this.mpInstance.Logger.verbose( | ||
| `mParticle APV: [fire] deferred flush -> _Events.logEvent(PageView) (path: ${path}, title: ${title})` | ||
| ); | ||
|
|
||
| this.mpInstance._Events.logEvent({ | ||
| messageType: MessageType.PageView, | ||
| name: 'PageView', | ||
| data: { | ||
| hostname: window.location.hostname, | ||
| title, | ||
| path, | ||
| }, | ||
| eventType: EventType.Unknown, | ||
| }); | ||
| } | ||
|
|
||
| public teardown(): void { | ||
| if (this.popStateListener) { | ||
| window.removeEventListener('popstate', this.popStateListener); | ||
| this.popStateListener = null; | ||
| } | ||
| const pushStateStillOurs = | ||
| this.pushStateWrapper !== null && | ||
| window.history.pushState === this.pushStateWrapper; | ||
| if (this.originalPushState) { | ||
| if (pushStateStillOurs) { | ||
| window.history.pushState = this.originalPushState; | ||
| this.mpInstance.Logger.verbose( | ||
| 'mParticle APV: [teardown] restored original pushState' | ||
| ); | ||
| } else { | ||
| this.mpInstance.Logger.verbose( | ||
| 'mParticle APV: [teardown] pushState no longer ours; leaving in place, gating callback to no-op' | ||
| ); | ||
| } | ||
| this.originalPushState = null; | ||
| this.pushStateWrapper = null; | ||
| } | ||
|
|
||
| const replaceStateStillOurs = | ||
| this.replaceStateWrapper !== null && | ||
| window.history.replaceState === this.replaceStateWrapper; | ||
| if (this.originalReplaceState) { | ||
| if (replaceStateStillOurs) { | ||
| window.history.replaceState = this.originalReplaceState; | ||
| this.mpInstance.Logger.verbose( | ||
| 'mParticle APV: [teardown] restored original replaceState' | ||
| ); | ||
| } else { | ||
| this.mpInstance.Logger.verbose( | ||
| 'mParticle APV: [teardown] replaceState no longer ours; leaving in place, gating callback to no-op' | ||
| ); | ||
| } | ||
| this.originalReplaceState = null; | ||
| this.replaceStateWrapper = null; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| this.isActive = false; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
_Eventsmodule to fire actual events, so this will likely go away.