diff --git a/.changeset/polite-olives-thank.md b/.changeset/polite-olives-thank.md new file mode 100644 index 000000000..b979700d1 --- /dev/null +++ b/.changeset/polite-olives-thank.md @@ -0,0 +1,6 @@ +--- +'@segment/analytics-signals': patch +'@segment/analytics-signals-runtime': patch +--- + +Add a changedProperties array to navigation signals diff --git a/packages/signals/signals-runtime/src/test-helpers/mocks/mock-signal-types-web.ts b/packages/signals/signals-runtime/src/test-helpers/mocks/mock-signal-types-web.ts index cac623b45..e1f5b10d0 100644 --- a/packages/signals/signals-runtime/src/test-helpers/mocks/mock-signal-types-web.ts +++ b/packages/signals/signals-runtime/src/test-helpers/mocks/mock-signal-types-web.ts @@ -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', diff --git a/packages/signals/signals-runtime/src/web/web-signals-types.ts b/packages/signals/signals-runtime/src/web/web-signals-types.ts index d5a17b569..ee3d8fd80 100644 --- a/packages/signals/signals-runtime/src/web/web-signals-types.ts +++ b/packages/signals/signals-runtime/src/web/web-signals-types.ts @@ -106,11 +106,15 @@ interface BaseNavigationData { 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 diff --git a/packages/signals/signals/src/core/signal-generators/dom-gen/__tests__/navigation-gen.test.ts b/packages/signals/signals/src/core/signal-generators/dom-gen/__tests__/navigation-gen.test.ts new file mode 100644 index 000000000..4f1fd64d5 --- /dev/null +++ b/packages/signals/signals/src/core/signal-generators/dom-gen/__tests__/navigation-gen.test.ts @@ -0,0 +1,164 @@ +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 + + 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": , + "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": , + "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']) + }) + + it('should stop emitting events after unsubscribe is called', () => { + const generator = new OnNavigationEventGenerator() + + const unsubscribe = generator.register(emitter) + + // Simulate a URL change + const newUrl = new URL(location.href) + newUrl.pathname = '/new-path' + setLocation({ + href: newUrl.href, + pathname: newUrl.pathname, + }) + + // Advance the timers to trigger the polling + jest.advanceTimersByTime(1000) + + // Ensure the event is emitted before unsubscribe + expect(emitSpy).toHaveBeenCalledTimes(2) + + // Unsubscribe the generator + unsubscribe() + + // Simulate another URL change + newUrl.pathname = '/another-path' + setLocation({ + href: newUrl.href, + pathname: newUrl.pathname, + }) + + // Advance the timers again + jest.advanceTimersByTime(1000) + + // Ensure no additional events are emitted after unsubscribe + expect(emitSpy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/signals/signals/src/core/signal-generators/dom-gen/dom-gen.ts b/packages/signals/signals/src/core/signal-generators/dom-gen/dom-gen.ts index b45ca1ec3..5f20c2594 100644 --- a/packages/signals/signals/src/core/signal-generators/dom-gen/dom-gen.ts +++ b/packages/signals/signals/src/core/signal-generators/dom-gen/dom-gen.ts @@ -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' @@ -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, - } - } -} diff --git a/packages/signals/signals/src/core/signal-generators/dom-gen/index.ts b/packages/signals/signals/src/core/signal-generators/dom-gen/index.ts index 1c2675154..7107d575b 100644 --- a/packages/signals/signals/src/core/signal-generators/dom-gen/index.ts +++ b/packages/signals/signals/src/core/signal-generators/dom-gen/index.ts @@ -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, diff --git a/packages/signals/signals/src/core/signal-generators/dom-gen/navigation-gen.ts b/packages/signals/signals/src/core/signal-generators/dom-gen/navigation-gen.ts new file mode 100644 index 000000000..bd5b133ac --- /dev/null +++ b/packages/signals/signals/src/core/signal-generators/dom-gen/navigation-gen.ts @@ -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 getChangedProperties(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: getChangedProperties(current, previous), + ...this.createCommonFields(), + }) + ) + ) + + return () => { + this.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, + } + } +} diff --git a/packages/signals/signals/src/lib/detect-url-change/index.ts b/packages/signals/signals/src/lib/detect-url-change/index.ts index 72a2d50c3..e5b74aa8d 100644 --- a/packages/signals/signals/src/lib/detect-url-change/index.ts +++ b/packages/signals/signals/src/lib/detect-url-change/index.ts @@ -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() } @@ -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) }