Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/polite-olives-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@segment/analytics-signals': patch
'@segment/analytics-signals-runtime': patch
---

Add a changedProperties array to navigation signals
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,11 @@ export const mockInteractionSignal: InteractionSignal = {
export const mockNavigationSignal: NavigationSignal = {
type: 'navigation',
data: {
page: mockPageData,
action: 'urlChange',
changedProperties: ['path'],
page: mockPageData,
path: '/',
search: '',
url: 'https://example.com',
hash: '#section1',
prevUrl: 'https://example.com/home',
Expand Down
4 changes: 4 additions & 0 deletions packages/signals/signals-runtime/src/web/web-signals-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,15 @@ interface BaseNavigationData<ActionType extends string> {
action: ActionType
url: string
hash: string
search: string
path: string
}

export type ChangedProperties = 'path' | 'search' | 'hash'
export interface URLChangeNavigationData
extends BaseNavigationData<'urlChange'> {
prevUrl: string
changedProperties: ChangedProperties[]
}

export interface PageChangeNavigationData
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { jest } from '@jest/globals'
import { URLChangeNavigationData } from '@segment/analytics-signals-runtime'
import { setLocation } from '../../../../test-helpers/set-location'
import { SignalEmitter } from '../../../emitter'
import { OnNavigationEventGenerator } from '../navigation-gen'

const originalLocation = window.location

describe(OnNavigationEventGenerator, () => {
let emitter: SignalEmitter
let emitSpy: jest.SpiedFunction<SignalEmitter['emit']>

beforeEach(() => {
setLocation(originalLocation)
jest.useFakeTimers()
emitter = new SignalEmitter()
emitSpy = jest.spyOn(emitter, 'emit')
})

afterEach(() => {
jest.clearAllMocks()
})

it('should emit an event with action "pageLoad" on initialization', () => {
const generator = new OnNavigationEventGenerator()
generator.register(emitter)
expect(emitSpy).toHaveBeenCalledTimes(1)
expect(emitSpy.mock.lastCall).toMatchInlineSnapshot(`
[
{
"anonymousId": "",
"data": {
"action": "pageLoad",
"hash": "",
"page": {
"hash": "",
"hostname": "localhost",
"path": "/",
"referrer": "",
"search": "",
"title": "",
"url": "http://localhost/",
},
"path": "/",
"search": "",
"title": "",
"url": "http://localhost/",
},
"timestamp": <ISO Timestamp>,
"type": "navigation",
},
]
`)
})

it('should emit an event with "action: urlChange" when the URL changes', () => {
const generator = new OnNavigationEventGenerator()

generator.register(emitter)

// Simulate a URL change
const newUrl = new URL(location.href)
newUrl.pathname = '/new-path'
newUrl.search = '?query=123'
newUrl.hash = '#hello'
setLocation({
href: newUrl.href,
pathname: newUrl.pathname,
search: newUrl.search,
hash: newUrl.hash,
})

// Advance the timers to trigger the polling
jest.advanceTimersByTime(1000)

expect(emitSpy).toHaveBeenCalledTimes(2)

expect(emitSpy.mock.lastCall).toMatchInlineSnapshot(`
[
{
"anonymousId": "",
"data": {
"action": "urlChange",
"changedProperties": [
"path",
"search",
"hash",
],
"hash": "#hello",
"page": {
"hash": "#hello",
"hostname": "localhost",
"path": "/new-path",
"referrer": "",
"search": "?query=123",
"title": "",
"url": "http://localhost/new-path?query=123#hello",
},
"path": "/new-path",
"prevUrl": "http://localhost/",
"search": "?query=123",
"title": "",
"url": "http://localhost/new-path?query=123#hello",
},
"timestamp": <ISO Timestamp>,
"type": "navigation",
},
]
`)
})

it('should only list the property that actually changed in changedProperties, and no more/less', () => {
const generator = new OnNavigationEventGenerator()

generator.register(emitter)

const newUrl = new URL(location.href)
newUrl.hash = '#hello'
setLocation({
href: newUrl.href,
hash: newUrl.hash,
})

jest.advanceTimersByTime(1000)
const lastCall = emitSpy.mock.lastCall![0].data as URLChangeNavigationData
expect(lastCall.changedProperties).toEqual(['hash'])
})
})
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import { URLChangeObservable } from '../../../lib/detect-url-change'
import {
createInteractionSignal,
createNavigationSignal,
} from '../../../types/factories'
import { createInteractionSignal } from '../../../types/factories'
import { SignalEmitter } from '../../emitter'
import { SignalGenerator } from '../types'
import { parseElement } from './element-parser'
Expand Down Expand Up @@ -78,44 +74,3 @@ export const shouldIgnoreElement = (el: HTMLElement): boolean => {
}
return false
}

export class OnNavigationEventGenerator implements SignalGenerator {
id = 'navigation'

register(emitter: SignalEmitter): () => void {
// emit navigation signal on page load
emitter.emit(
createNavigationSignal({
action: 'pageLoad',
...this.createCommonFields(),
})
)

// emit a navigation signal whenever the URL has changed
const urlChange = new URLChangeObservable()
urlChange.subscribe((prevUrl) =>
emitter.emit(
createNavigationSignal({
action: 'urlChange',
prevUrl,
...this.createCommonFields(),
})
)
)

return () => {
urlChange.unsubscribe()
}
}

private createCommonFields() {
return {
// these fields are named after those from the page call, rather than a DOM api.
url: location.href,
path: location.pathname,
hash: location.hash,
search: location.search,
title: document.title,
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import {
ClickSignalsGenerator,
FormSubmitGenerator,
OnNavigationEventGenerator,
} from './dom-gen'
import { ClickSignalsGenerator, FormSubmitGenerator } from './dom-gen'
import {
MutationChangeGenerator,
OnChangeGenerator,
ContentEditableChangeGenerator,
} from './change-gen'
import { SignalGeneratorClass } from '../types'
import { OnNavigationEventGenerator } from './navigation-gen'

export const domGenerators: SignalGeneratorClass[] = [
MutationChangeGenerator,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { ChangedProperties } from '@segment/analytics-signals-runtime'
import { URLChangeObservable } from '../../../lib/detect-url-change'
import { createNavigationSignal } from '../../../types/factories'
import { SignalEmitter } from '../../emitter'
import { SignalGenerator } from '../types'

function getURLDifferences(url1: URL, url2: URL): ChangedProperties[] {
const changed: ChangedProperties[] = []
const propertiesToCompare = ['pathname', 'search', 'hash'] as const

for (const property of propertiesToCompare) {
if (url1[property] !== url2[property]) {
if (property === 'pathname') {
changed.push('path')
} else {
changed.push(property)
}
}
}

return changed
}

export class OnNavigationEventGenerator implements SignalGenerator {
id = 'navigation'

urlChange: URLChangeObservable
constructor() {
this.urlChange = new URLChangeObservable()
}

register(emitter: SignalEmitter): () => void {
// emit navigation signal on page load
emitter.emit(
createNavigationSignal({
action: 'pageLoad',
...this.createCommonFields(),
})
)

// emit a navigation signal whenever the URL has changed
this.urlChange.subscribe(({ previous, current }) =>
emitter.emit(
createNavigationSignal({
action: 'urlChange',
prevUrl: previous.href,
changedProperties: getURLDifferences(current, previous),
...this.createCommonFields(),
})
)
)

return () => {
this.urlChange.unsubscribe()

Check warning on line 54 in packages/signals/signals/src/core/signal-generators/dom-gen/navigation-gen.ts

View check run for this annotation

Codecov / codecov/patch

packages/signals/signals/src/core/signal-generators/dom-gen/navigation-gen.ts#L54

Added line #L54 was not covered by tests
}
}

private createCommonFields() {
return {
// these fields are named after those from the page call, rather than a DOM api.
url: location.href,
path: location.pathname,
hash: location.hash,
search: location.search,
title: document.title,
}
}
}
26 changes: 20 additions & 6 deletions packages/signals/signals/src/lib/detect-url-change/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { Emitter } from '@segment/analytics-generic-utils'

type ChangeData = {
current: URL
previous: URL
}
export interface URLChangeObservableSettings {
pollInterval?: number
}

// This seems hacky, but if using react router (or other SPAs), popstate will not always fire on navigation
// Otherwise, we could use popstate / hashchange events
export class URLChangeObservable {
private emitter = new Emitter()
private pollInterval = 500
urlChanged?: (url: string) => void
constructor() {
private emitter = new Emitter<{ change: [ChangeData] }>()
private pollInterval: number
private urlChanged?: (data: ChangeData) => void
constructor(settings: URLChangeObservableSettings = {}) {
this.pollInterval = settings.pollInterval ?? 500
this.pollURLChange()
}

Expand All @@ -15,13 +24,18 @@ export class URLChangeObservable {
setInterval(() => {
const currentUrl = window.location.href
if (currentUrl != prevUrl) {
this.emitter.emit('change', prevUrl)
const current = new URL(currentUrl)
const prev = new URL(prevUrl)
this.emitter.emit('change', {
current,
previous: prev,
})
prevUrl = currentUrl
}
}, this.pollInterval)
}

subscribe(urlChanged: (newURL: string) => void) {
subscribe(urlChanged: (data: ChangeData) => void) {
this.urlChanged = urlChanged
this.emitter.on('change', this.urlChanged)
}
Expand Down