diff --git a/packages/rum/src/viewCollection.ts b/packages/rum/src/viewCollection.ts index ced8a5c556..12eb76bc3b 100644 --- a/packages/rum/src/viewCollection.ts +++ b/packages/rum/src/viewCollection.ts @@ -47,6 +47,10 @@ export function startViewCollection(location: Location, lifeCycle: LifeCycle) { currentView.triggerUpdate() currentView.end() currentView = newView(lifeCycle, currentLocation, ViewLoadingType.ROUTE_CHANGE) + } else { + // Anchor navigations would modify the location without generating a new view. + // These changes need to be acknowledged so they don't interfere with the next areDifferentViews call + currentLocation = { ...location } } } @@ -174,8 +178,13 @@ function trackHash(onHashChange: () => void) { window.addEventListener('hashchange', monitor(onHashChange)) } +function isHashAnAnchor(hash: string) { + const correspondingId = hash.substr(1) + return !!document.getElementById(correspondingId) +} + function areDifferentViews(previous: Location, current: Location): boolean { - return previous.pathname !== current.pathname || previous.hash !== current.hash + return previous.pathname !== current.pathname || (!isHashAnAnchor(current.hash) && previous.hash !== current.hash) } interface Timings { diff --git a/packages/rum/test/viewCollection.spec.ts b/packages/rum/test/viewCollection.spec.ts index 2122c82f60..a1caae353b 100644 --- a/packages/rum/test/viewCollection.spec.ts +++ b/packages/rum/test/viewCollection.spec.ts @@ -83,6 +83,12 @@ function mockHash(location: Partial) { } } +function mockGetElementById() { + return spyOn(document, 'getElementById').and.callFake((elementId: string) => { + return (elementId === ('testHashValue' as unknown)) as any + }) +} + function spyOnViews() { const handler = jasmine.createSpy() @@ -191,6 +197,45 @@ describe('rum track url change', () => { window.location.hash = '#bar' }) + it('should not create a new view when it is an Anchor navigation', (done) => { + const { lifeCycle } = setupBuilder.build() + mockGetElementById() + lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, createSpy) + + function hashchangeCallBack() { + expect(createSpy).not.toHaveBeenCalled() + window.removeEventListener('hashchange', hashchangeCallBack) + done() + } + + window.addEventListener('hashchange', hashchangeCallBack) + + window.location.hash = '#testHashValue' + }) + + it('should acknowledge the view location hash change after an Anchor navigation', (done) => { + const { lifeCycle } = setupBuilder.build() + const spyObj = mockGetElementById() + lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, createSpy) + + function hashchangeCallBack() { + expect(createSpy).not.toHaveBeenCalled() + window.removeEventListener('hashchange', hashchangeCallBack) + + // clear mockGetElementById that fake Anchor nav + spyObj.and.callThrough() + + // This is not an Anchor nav anymore but the hash and pathname have not been updated + history.pushState({}, '', '/foo#testHashValue') + expect(createSpy).not.toHaveBeenCalled() + done() + } + + window.addEventListener('hashchange', hashchangeCallBack) + + window.location.hash = '#testHashValue' + }) + it('should not create new view on search change', () => { const { lifeCycle } = setupBuilder.build() lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, createSpy) diff --git a/test/e2e/scenario/agents.scenario.ts b/test/e2e/scenario/agents.scenario.ts index 595a7d6853..d878be7783 100644 --- a/test/e2e/scenario/agents.scenario.ts +++ b/test/e2e/scenario/agents.scenario.ts @@ -16,7 +16,7 @@ import { waitServerRumEvents, withBrowserLogs, } from './helpers' -import { isRumResourceEvent, isRumUserActionEvent, isRumViewEvent } from './serverTypes' +import { isRumResourceEvent, isRumUserActionEvent, isRumViewEvent, ServerRumViewLoadingType } from './serverTypes' beforeEach(startSpec) @@ -270,3 +270,30 @@ describe('user action collection', () => { expect(resourceEvents[0].user_action!.id).toBe(userActionEvents[0].user_action.id!) }) }) + +describe('anchor navigation', () => { + it('should not create a new view when it is an Anchor navigation', async () => { + await $('#test-anchor').click() + + await flushEvents() + const rumEvents = await waitServerRumEvents() + const viewEvents = rumEvents.filter(isRumViewEvent) + + expect(viewEvents.length).toBe(1) + expect(viewEvents[0].view.loading_type).toBe(ServerRumViewLoadingType.INITIAL_LOAD) + }) + + it('should create a new view on hash change', async () => { + await browserExecute(() => { + window.location.hash = '#bar' + }) + + await flushEvents() + const rumEvents = await waitServerRumEvents() + const viewEvents = rumEvents.filter(isRumViewEvent) + + expect(viewEvents.length).toBe(2) + expect(viewEvents[0].view.loading_type).toBe(ServerRumViewLoadingType.INITIAL_LOAD) + expect(viewEvents[1].view.loading_type).toBe(ServerRumViewLoadingType.ROUTE_CHANGE) + }) +}) diff --git a/test/e2e/scenario/serverTypes.ts b/test/e2e/scenario/serverTypes.ts index 0339cbb4d4..c988fa78e2 100644 --- a/test/e2e/scenario/serverTypes.ts +++ b/test/e2e/scenario/serverTypes.ts @@ -80,6 +80,11 @@ export function isRumUserActionEvent(event: ServerRumEvent): event is ServerRumU return event.evt.category === 'user_action' } +export enum ServerRumViewLoadingType { + INITIAL_LOAD = 'initial_load', + ROUTE_CHANGE = 'route_change', +} + export interface ServerRumViewEvent extends ServerRumEvent { evt: { category: 'view' @@ -90,6 +95,7 @@ export interface ServerRumViewEvent extends ServerRumEvent { session_id: string view: { id: string + loading_type: ServerRumViewLoadingType measures: { dom_complete: number dom_content_loaded: number diff --git a/test/static/async-e2e-page.html b/test/static/async-e2e-page.html index 0bc874d027..0fd6dec29a 100644 --- a/test/static/async-e2e-page.html +++ b/test/static/async-e2e-page.html @@ -9,6 +9,9 @@ +
+ anchor link +