Skip to content

Commit a00fa28

Browse files
authored
Add changedProperties to navigation signals (#1274)
1 parent f2c2b76 commit a00fa28

File tree

8 files changed

+269
-58
lines changed

8 files changed

+269
-58
lines changed

.changeset/polite-olives-thank.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@segment/analytics-signals': patch
3+
'@segment/analytics-signals-runtime': patch
4+
---
5+
6+
Add a changedProperties array to navigation signals

packages/signals/signals-runtime/src/test-helpers/mocks/mock-signal-types-web.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,11 @@ export const mockInteractionSignal: InteractionSignal = {
4343
export const mockNavigationSignal: NavigationSignal = {
4444
type: 'navigation',
4545
data: {
46-
page: mockPageData,
4746
action: 'urlChange',
47+
changedProperties: ['path'],
48+
page: mockPageData,
49+
path: '/',
50+
search: '',
4851
url: 'https://example.com',
4952
hash: '#section1',
5053
prevUrl: 'https://example.com/home',

packages/signals/signals-runtime/src/web/web-signals-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,15 @@ interface BaseNavigationData<ActionType extends string> {
106106
action: ActionType
107107
url: string
108108
hash: string
109+
search: string
110+
path: string
109111
}
110112

113+
export type ChangedProperties = 'path' | 'search' | 'hash'
111114
export interface URLChangeNavigationData
112115
extends BaseNavigationData<'urlChange'> {
113116
prevUrl: string
117+
changedProperties: ChangedProperties[]
114118
}
115119

116120
export interface PageChangeNavigationData
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { jest } from '@jest/globals'
2+
import { URLChangeNavigationData } from '@segment/analytics-signals-runtime'
3+
import { setLocation } from '../../../../test-helpers/set-location'
4+
import { SignalEmitter } from '../../../emitter'
5+
import { OnNavigationEventGenerator } from '../navigation-gen'
6+
7+
const originalLocation = window.location
8+
9+
describe(OnNavigationEventGenerator, () => {
10+
let emitter: SignalEmitter
11+
let emitSpy: jest.SpiedFunction<SignalEmitter['emit']>
12+
13+
beforeEach(() => {
14+
setLocation(originalLocation)
15+
jest.useFakeTimers()
16+
emitter = new SignalEmitter()
17+
emitSpy = jest.spyOn(emitter, 'emit')
18+
})
19+
20+
afterEach(() => {
21+
jest.clearAllMocks()
22+
})
23+
24+
it('should emit an event with action "pageLoad" on initialization', () => {
25+
const generator = new OnNavigationEventGenerator()
26+
generator.register(emitter)
27+
expect(emitSpy).toHaveBeenCalledTimes(1)
28+
expect(emitSpy.mock.lastCall).toMatchInlineSnapshot(`
29+
[
30+
{
31+
"anonymousId": "",
32+
"data": {
33+
"action": "pageLoad",
34+
"hash": "",
35+
"page": {
36+
"hash": "",
37+
"hostname": "localhost",
38+
"path": "/",
39+
"referrer": "",
40+
"search": "",
41+
"title": "",
42+
"url": "http://localhost/",
43+
},
44+
"path": "/",
45+
"search": "",
46+
"title": "",
47+
"url": "http://localhost/",
48+
},
49+
"timestamp": <ISO Timestamp>,
50+
"type": "navigation",
51+
},
52+
]
53+
`)
54+
})
55+
56+
it('should emit an event with "action: urlChange" when the URL changes', () => {
57+
const generator = new OnNavigationEventGenerator()
58+
59+
generator.register(emitter)
60+
61+
// Simulate a URL change
62+
const newUrl = new URL(location.href)
63+
newUrl.pathname = '/new-path'
64+
newUrl.search = '?query=123'
65+
newUrl.hash = '#hello'
66+
setLocation({
67+
href: newUrl.href,
68+
pathname: newUrl.pathname,
69+
search: newUrl.search,
70+
hash: newUrl.hash,
71+
})
72+
73+
// Advance the timers to trigger the polling
74+
jest.advanceTimersByTime(1000)
75+
76+
expect(emitSpy).toHaveBeenCalledTimes(2)
77+
78+
expect(emitSpy.mock.lastCall).toMatchInlineSnapshot(`
79+
[
80+
{
81+
"anonymousId": "",
82+
"data": {
83+
"action": "urlChange",
84+
"changedProperties": [
85+
"path",
86+
"search",
87+
"hash",
88+
],
89+
"hash": "#hello",
90+
"page": {
91+
"hash": "#hello",
92+
"hostname": "localhost",
93+
"path": "/new-path",
94+
"referrer": "",
95+
"search": "?query=123",
96+
"title": "",
97+
"url": "http://localhost/new-path?query=123#hello",
98+
},
99+
"path": "/new-path",
100+
"prevUrl": "http://localhost/",
101+
"search": "?query=123",
102+
"title": "",
103+
"url": "http://localhost/new-path?query=123#hello",
104+
},
105+
"timestamp": <ISO Timestamp>,
106+
"type": "navigation",
107+
},
108+
]
109+
`)
110+
})
111+
112+
it('should only list the property that actually changed in changedProperties, and no more/less', () => {
113+
const generator = new OnNavigationEventGenerator()
114+
115+
generator.register(emitter)
116+
117+
const newUrl = new URL(location.href)
118+
newUrl.hash = '#hello'
119+
setLocation({
120+
href: newUrl.href,
121+
hash: newUrl.hash,
122+
})
123+
124+
jest.advanceTimersByTime(1000)
125+
const lastCall = emitSpy.mock.lastCall![0].data as URLChangeNavigationData
126+
expect(lastCall.changedProperties).toEqual(['hash'])
127+
})
128+
129+
it('should stop emitting events after unsubscribe is called', () => {
130+
const generator = new OnNavigationEventGenerator()
131+
132+
const unsubscribe = generator.register(emitter)
133+
134+
// Simulate a URL change
135+
const newUrl = new URL(location.href)
136+
newUrl.pathname = '/new-path'
137+
setLocation({
138+
href: newUrl.href,
139+
pathname: newUrl.pathname,
140+
})
141+
142+
// Advance the timers to trigger the polling
143+
jest.advanceTimersByTime(1000)
144+
145+
// Ensure the event is emitted before unsubscribe
146+
expect(emitSpy).toHaveBeenCalledTimes(2)
147+
148+
// Unsubscribe the generator
149+
unsubscribe()
150+
151+
// Simulate another URL change
152+
newUrl.pathname = '/another-path'
153+
setLocation({
154+
href: newUrl.href,
155+
pathname: newUrl.pathname,
156+
})
157+
158+
// Advance the timers again
159+
jest.advanceTimersByTime(1000)
160+
161+
// Ensure no additional events are emitted after unsubscribe
162+
expect(emitSpy).toHaveBeenCalledTimes(2)
163+
})
164+
})

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

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import { URLChangeObservable } from '../../../lib/detect-url-change'
2-
import {
3-
createInteractionSignal,
4-
createNavigationSignal,
5-
} from '../../../types/factories'
1+
import { createInteractionSignal } from '../../../types/factories'
62
import { SignalEmitter } from '../../emitter'
73
import { SignalGenerator } from '../types'
84
import { parseElement } from './element-parser'
@@ -78,44 +74,3 @@ export const shouldIgnoreElement = (el: HTMLElement): boolean => {
7874
}
7975
return false
8076
}
81-
82-
export class OnNavigationEventGenerator implements SignalGenerator {
83-
id = 'navigation'
84-
85-
register(emitter: SignalEmitter): () => void {
86-
// emit navigation signal on page load
87-
emitter.emit(
88-
createNavigationSignal({
89-
action: 'pageLoad',
90-
...this.createCommonFields(),
91-
})
92-
)
93-
94-
// emit a navigation signal whenever the URL has changed
95-
const urlChange = new URLChangeObservable()
96-
urlChange.subscribe((prevUrl) =>
97-
emitter.emit(
98-
createNavigationSignal({
99-
action: 'urlChange',
100-
prevUrl,
101-
...this.createCommonFields(),
102-
})
103-
)
104-
)
105-
106-
return () => {
107-
urlChange.unsubscribe()
108-
}
109-
}
110-
111-
private createCommonFields() {
112-
return {
113-
// these fields are named after those from the page call, rather than a DOM api.
114-
url: location.href,
115-
path: location.pathname,
116-
hash: location.hash,
117-
search: location.search,
118-
title: document.title,
119-
}
120-
}
121-
}

packages/signals/signals/src/core/signal-generators/dom-gen/index.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import {
2-
ClickSignalsGenerator,
3-
FormSubmitGenerator,
4-
OnNavigationEventGenerator,
5-
} from './dom-gen'
1+
import { ClickSignalsGenerator, FormSubmitGenerator } from './dom-gen'
62
import {
73
MutationChangeGenerator,
84
OnChangeGenerator,
95
ContentEditableChangeGenerator,
106
} from './change-gen'
117
import { SignalGeneratorClass } from '../types'
8+
import { OnNavigationEventGenerator } from './navigation-gen'
129

1310
export const domGenerators: SignalGeneratorClass[] = [
1411
MutationChangeGenerator,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { ChangedProperties } from '@segment/analytics-signals-runtime'
2+
import { URLChangeObservable } from '../../../lib/detect-url-change'
3+
import { createNavigationSignal } from '../../../types/factories'
4+
import { SignalEmitter } from '../../emitter'
5+
import { SignalGenerator } from '../types'
6+
7+
function getChangedProperties(url1: URL, url2: URL): ChangedProperties[] {
8+
const changed: ChangedProperties[] = []
9+
const propertiesToCompare = ['pathname', 'search', 'hash'] as const
10+
11+
for (const property of propertiesToCompare) {
12+
if (url1[property] !== url2[property]) {
13+
if (property === 'pathname') {
14+
changed.push('path')
15+
} else {
16+
changed.push(property)
17+
}
18+
}
19+
}
20+
21+
return changed
22+
}
23+
24+
export class OnNavigationEventGenerator implements SignalGenerator {
25+
id = 'navigation'
26+
27+
urlChange: URLChangeObservable
28+
constructor() {
29+
this.urlChange = new URLChangeObservable()
30+
}
31+
32+
register(emitter: SignalEmitter): () => void {
33+
// emit navigation signal on page load
34+
emitter.emit(
35+
createNavigationSignal({
36+
action: 'pageLoad',
37+
...this.createCommonFields(),
38+
})
39+
)
40+
41+
// emit a navigation signal whenever the URL has changed
42+
this.urlChange.subscribe(({ previous, current }) =>
43+
emitter.emit(
44+
createNavigationSignal({
45+
action: 'urlChange',
46+
prevUrl: previous.href,
47+
changedProperties: getChangedProperties(current, previous),
48+
...this.createCommonFields(),
49+
})
50+
)
51+
)
52+
53+
return () => {
54+
this.urlChange.unsubscribe()
55+
}
56+
}
57+
58+
private createCommonFields() {
59+
return {
60+
// these fields are named after those from the page call, rather than a DOM api.
61+
url: location.href,
62+
path: location.pathname,
63+
hash: location.hash,
64+
search: location.search,
65+
title: document.title,
66+
}
67+
}
68+
}

packages/signals/signals/src/lib/detect-url-change/index.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
import { Emitter } from '@segment/analytics-generic-utils'
22

3+
type ChangeData = {
4+
current: URL
5+
previous: URL
6+
}
7+
export interface URLChangeObservableSettings {
8+
pollInterval?: number
9+
}
10+
311
// This seems hacky, but if using react router (or other SPAs), popstate will not always fire on navigation
412
// Otherwise, we could use popstate / hashchange events
513
export class URLChangeObservable {
6-
private emitter = new Emitter()
7-
private pollInterval = 500
8-
urlChanged?: (url: string) => void
9-
constructor() {
14+
private emitter = new Emitter<{ change: [ChangeData] }>()
15+
private pollInterval: number
16+
private urlChanged?: (data: ChangeData) => void
17+
constructor(settings: URLChangeObservableSettings = {}) {
18+
this.pollInterval = settings.pollInterval ?? 500
1019
this.pollURLChange()
1120
}
1221

@@ -15,13 +24,18 @@ export class URLChangeObservable {
1524
setInterval(() => {
1625
const currentUrl = window.location.href
1726
if (currentUrl != prevUrl) {
18-
this.emitter.emit('change', prevUrl)
27+
const current = new URL(currentUrl)
28+
const prev = new URL(prevUrl)
29+
this.emitter.emit('change', {
30+
current,
31+
previous: prev,
32+
})
1933
prevUrl = currentUrl
2034
}
2135
}, this.pollInterval)
2236
}
2337

24-
subscribe(urlChanged: (newURL: string) => void) {
38+
subscribe(urlChanged: (data: ChangeData) => void) {
2539
this.urlChanged = urlChanged
2640
this.emitter.on('change', this.urlChanged)
2741
}

0 commit comments

Comments
 (0)