Skip to content
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

SDK: better support for SPA routing - STEP 1/2 : add hash tracking #463

19 changes: 14 additions & 5 deletions packages/rum/src/viewCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,20 @@ export function startViewCollection(location: Location, lifeCycle: LifeCycle) {
const startOrigin = 0
let currentView = newView(lifeCycle, currentLocation, ViewLoadingType.INITIAL_LOAD, startOrigin)

// Renew view on history changes
trackHistory(() => {
function renewViewOnChange() {
if (areDifferentViews(currentLocation, location)) {
currentLocation = { ...location }
currentView.triggerUpdate()
currentView.end()
currentView = newView(lifeCycle, currentLocation, ViewLoadingType.ROUTE_CHANGE)
}
})
}

// Renew view on history changes
trackHistory(renewViewOnChange)

// Renew view on hash changes
trackHash(renewViewOnChange)

// Renew view on session renewal
lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => {
Expand Down Expand Up @@ -165,8 +170,12 @@ function trackHistory(onHistoryChange: () => void) {
window.addEventListener(DOM_EVENT.POP_STATE, monitor(onHistoryChange))
}

function areDifferentViews(previous: Location, current: Location) {
return previous.pathname !== current.pathname
function trackHash(onHashChange: () => void) {
window.addEventListener('hashchange', monitor(onHashChange))
}

function areDifferentViews(previous: Location, current: Location): boolean {
return previous.pathname !== current.pathname || previous.hash !== current.hash
}

interface Timings {
Expand Down
75 changes: 69 additions & 6 deletions packages/rum/test/viewCollection.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getHash, getPathName, getSearch } from '@datadog/browser-core'
import { getHash, getPathName, getSearch, noop } from '@datadog/browser-core'

import { LifeCycleEventType } from '../src/lifeCycle'
import { ViewContext } from '../src/parentContexts'
Expand Down Expand Up @@ -66,10 +66,23 @@ function mockHistory(location: Partial<Location>) {
const url = `http://localhost${pathname}`
location.pathname = getPathName(url)
location.search = getSearch(url)
location.hash = getHash(url)
location.hash = getHash(url) || ''
})
}

function mockHash(location: Partial<Location>) {
function hashchangeCallBack() {
location.hash = window.location.hash
}

window.addEventListener('hashchange', hashchangeCallBack)

return () => {
window.removeEventListener('hashchange', hashchangeCallBack)
window.location.hash = ''
}
}

function spyOnViews() {
const handler = jasmine.createSpy()

Expand All @@ -88,10 +101,12 @@ describe('rum track url change', () => {
let setupBuilder: TestSetupBuilder
let initialViewId: string
let createSpy: jasmine.Spy
let cleanMockHash: () => void

beforeEach(() => {
const fakeLocation: Partial<Location> = { pathname: '/foo' }
const fakeLocation: Partial<Location> = { pathname: '/foo', hash: '' }
mockHistory(fakeLocation)
cleanMockHash = mockHash(fakeLocation)
setupBuilder = setup()
.withFakeLocation(fakeLocation)
.withViewCollection()
Expand All @@ -106,6 +121,7 @@ describe('rum track url change', () => {

afterEach(() => {
setupBuilder.cleanup()
cleanMockHash()
})

it('should create new view on path change', () => {
Expand All @@ -119,21 +135,68 @@ describe('rum track url change', () => {
expect(viewContext.id).not.toEqual(initialViewId)
})

it('should not create new view on search change', () => {
it('should create a new view on hash change from history', () => {
const { lifeCycle } = setupBuilder.build()
lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, createSpy)

history.pushState({}, '', '/foo?bar=qux')
history.pushState({}, '', '/foo#bar')

expect(createSpy).toHaveBeenCalled()
const viewContext = createSpy.calls.argsFor(0)[0] as ViewContext
expect(viewContext.id).not.toEqual(initialViewId)
})

it('should not create a new view on hash change from history when the hash has kept the same value', () => {
history.pushState({}, '', '/foo#bar')

const { lifeCycle } = setupBuilder.build()
lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, createSpy)

history.pushState({}, '', '/foo#bar')

expect(createSpy).not.toHaveBeenCalled()
})

it('should not create a new view on hash change', () => {
it('should create a new view on hash change', (done) => {
const { lifeCycle } = setupBuilder.build()
lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, createSpy)

function hashchangeCallBack() {
expect(createSpy).toHaveBeenCalled()
const viewContext = createSpy.calls.argsFor(0)[0] as ViewContext
expect(viewContext.id).not.toEqual(initialViewId)
mquentin marked this conversation as resolved.
Show resolved Hide resolved
window.removeEventListener('hashchange', hashchangeCallBack)
done()
}

window.addEventListener('hashchange', hashchangeCallBack)

window.location.hash = '#bar'
})

it('should not create a new view when the hash has kept the same value', (done) => {
history.pushState({}, '', '/foo#bar')

const { lifeCycle } = setupBuilder.build()
lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, createSpy)

function hashchangeCallBack() {
expect(createSpy).not.toHaveBeenCalled()
mquentin marked this conversation as resolved.
Show resolved Hide resolved
window.removeEventListener('hashchange', hashchangeCallBack)
done()
}

window.addEventListener('hashchange', hashchangeCallBack)

window.location.hash = '#bar'
})

it('should not create new view on search change', () => {
const { lifeCycle } = setupBuilder.build()
lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, createSpy)

history.pushState({}, '', '/foo?bar=qux')

expect(createSpy).not.toHaveBeenCalled()
})
})
Expand Down