diff --git a/eslint.config.mjs b/eslint.config.mjs
index b123854..3123148 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -19,7 +19,7 @@ export default [
'error',
{
enforceBuildableLibDependency: true,
- allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
+ allow: [String.raw`^.*/eslint(\.base)?\.config\.[cm]?[jt]s$`],
depConstraints: [
{
sourceTag: 'scope:adapter',
diff --git a/packages/core/src/autocapture/click.test.ts b/packages/core/src/autocapture/click.test.ts
index 3d2b2df..0bb76fe 100644
--- a/packages/core/src/autocapture/click.test.ts
+++ b/packages/core/src/autocapture/click.test.ts
@@ -84,6 +84,18 @@ describe('Click tracking', () => {
expect(mockTrack).not.toHaveBeenCalled();
});
+ it('tracks explicitly marked elements via data-analytics-track', () => {
+ document.body.innerHTML = `
+
Track this
+ `;
+ document.querySelector('div')!.click();
+
+ expect(mockTrack).toHaveBeenCalledWith(
+ 'click',
+ expect.objectContaining({ tag: 'div', text: 'Track this' }),
+ );
+ });
+
it('truncates long text to 50 characters', () => {
const longText = 'A'.repeat(100);
document.body.innerHTML = ``;
diff --git a/packages/core/src/autocapture/click.ts b/packages/core/src/autocapture/click.ts
index 3454625..d13587c 100644
--- a/packages/core/src/autocapture/click.ts
+++ b/packages/core/src/autocapture/click.ts
@@ -91,10 +91,10 @@ function findTrackableElement(target: HTMLElement): HTMLElement | null {
if (!current) return null;
// Explicitly ignored element
- if (current.hasAttribute('data-analytics-ignore')) return null;
+ if (current.dataset.analyticsIgnore !== undefined) return null;
// Explicitly tracked element
- if (current.hasAttribute('data-analytics-track')) return current;
+ if (current.dataset.analyticsTrack !== undefined) return current;
// Naturally trackable element
if (TRACKED_TAGS.has(current.tagName)) return current;
@@ -142,7 +142,7 @@ function getElementText(element: HTMLElement): string {
''
)
.trim()
- .replace(/\s+/g, ' ');
+ .replaceAll(/\s+/g, ' ');
return text.slice(0, MAX_TEXT_LENGTH);
}
@@ -150,7 +150,7 @@ function getElementText(element: HTMLElement): string {
function isExternalLink(href: string): boolean {
try {
const url = new URL(href);
- return url.hostname !== window.location.hostname;
+ return url.hostname !== globalThis.location.hostname;
} catch {
return false;
}
diff --git a/packages/core/src/autocapture/form.test.ts b/packages/core/src/autocapture/form.test.ts
index 29bcb83..8c08bf7 100644
--- a/packages/core/src/autocapture/form.test.ts
+++ b/packages/core/src/autocapture/form.test.ts
@@ -80,6 +80,17 @@ describe('Form tracking', () => {
expect(mockTrack).not.toHaveBeenCalled();
});
+ it('respects data-analytics-ignore attribute values', () => {
+ document.body.innerHTML = `
+
+ `;
+ submitForm(document.querySelector('form')!);
+ expect(mockTrack).not.toHaveBeenCalled();
+ });
+
it('does not track after detach', () => {
detachFormTracker();
document.body.innerHTML = `
diff --git a/packages/core/src/autocapture/form.ts b/packages/core/src/autocapture/form.ts
index aaa4013..a0c0a8a 100644
--- a/packages/core/src/autocapture/form.ts
+++ b/packages/core/src/autocapture/form.ts
@@ -44,7 +44,7 @@ function handleSubmit(event: SubmitEvent): void {
if (hasPasswordField(form)) return;
// Never track forms explicitly opted out
- if (form.hasAttribute('data-analytics-ignore')) return;
+ if (form.dataset.analyticsIgnore !== undefined) return;
_track(EventTypes.FORM_SUBMIT, {
eventName: 'form_submit',
diff --git a/packages/core/src/autocapture/page-view.test.ts b/packages/core/src/autocapture/page-view.test.ts
index 835399e..00d9140 100644
--- a/packages/core/src/autocapture/page-view.test.ts
+++ b/packages/core/src/autocapture/page-view.test.ts
@@ -46,6 +46,9 @@ function createBrowserHarness(initialHref = 'https://example.com/start') {
};
vi.stubGlobal('window', windowObj);
+ vi.stubGlobal('location', location);
+ vi.stubGlobal('addEventListener', windowObj.addEventListener);
+ vi.stubGlobal('removeEventListener', windowObj.removeEventListener);
vi.stubGlobal('history', historyObj);
vi.stubGlobal('document', { title: 'Initial title' });
diff --git a/packages/core/src/autocapture/page-view.ts b/packages/core/src/autocapture/page-view.ts
index 51a2edc..68b53c9 100644
--- a/packages/core/src/autocapture/page-view.ts
+++ b/packages/core/src/autocapture/page-view.ts
@@ -24,7 +24,7 @@ let _track: TrackFn | null = null;
export function attachPageViewTracker(opts: PageViewTrackerOptions): void {
_track = opts.track;
- _lastUrl = window.location.href;
+ _lastUrl = globalThis.location.href;
patchHistory();
attachPopStateListener();
@@ -81,12 +81,12 @@ function restoreHistory(): void {
function attachPopStateListener(): void {
_onPopState = () => handleNavigation('pop');
- window.addEventListener('popstate', _onPopState);
+ globalThis.addEventListener('popstate', _onPopState);
}
function detachPopStateListener(): void {
if (_onPopState !== null) {
- window.removeEventListener('popstate', _onPopState);
+ globalThis.removeEventListener('popstate', _onPopState);
_onPopState = null;
}
}
@@ -101,8 +101,8 @@ function handleNavigation(type: NavigationType): void {
}
function fireIfUrlChanged(type: NavigationType): void {
- const currentUrl = window.location.href;
- const currentPathname = window.location.pathname;
+ const currentUrl = globalThis.location.href;
+ const currentPathname = globalThis.location.pathname;
const lastPathname = extractPathname(_lastUrl);
if (currentUrl === _lastUrl) return;
diff --git a/packages/core/src/autocapture/scroll-depth.test.ts b/packages/core/src/autocapture/scroll-depth.test.ts
index 057d51c..a52d951 100644
--- a/packages/core/src/autocapture/scroll-depth.test.ts
+++ b/packages/core/src/autocapture/scroll-depth.test.ts
@@ -48,14 +48,14 @@ beforeEach(() => {
mockTrack.mockClear();
document.body.innerHTML = '';
- // Create a proper constructor function that works with 'new' operator
- class IntersectionObserverMock extends MockIntersectionObserver {
- constructor(cb: IntersectionObserverCallback) {
- super(cb);
- // eslint-disable-next-line @typescript-eslint/no-this-alias
- mockObserver = this;
- }
- }
+ // Return the shared mock instance while still supporting `new IntersectionObserver(...)`.
+ const IntersectionObserverMock = function (
+ this: unknown,
+ cb: IntersectionObserverCallback,
+ ) {
+ mockObserver = new MockIntersectionObserver(cb);
+ return mockObserver;
+ } as unknown as typeof IntersectionObserver;
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
@@ -70,6 +70,7 @@ afterEach(() => {
describe('Scroll depth tracking', () => {
it('fires scroll event when sentinel becomes visible', () => {
const sentinel = document.querySelector('[data-analytics-sentinel="25"]')!;
+ expect((sentinel as HTMLElement).dataset.analyticsSentinel).toBe('25');
mockObserver.trigger(sentinel);
expect(mockTrack).toHaveBeenCalledWith(
diff --git a/packages/core/src/autocapture/scroll-depth.ts b/packages/core/src/autocapture/scroll-depth.ts
index e6d68a3..323602b 100644
--- a/packages/core/src/autocapture/scroll-depth.ts
+++ b/packages/core/src/autocapture/scroll-depth.ts
@@ -77,7 +77,7 @@ function placeSentinels(): void {
MILESTONES.forEach((milestone) => {
const sentinel = document.createElement('div');
- sentinel.setAttribute('data-analytics-sentinel', String(milestone));
+ sentinel.dataset.analyticsSentinel = String(milestone);
sentinel.setAttribute('aria-hidden', 'true');
// Position absolutely at the milestone depth
@@ -101,7 +101,7 @@ function placeSentinels(): void {
function removeSentinels(): void {
_sentinels.forEach((sentinel) => {
if (sentinel.parentNode) {
- sentinel.parentNode.removeChild(sentinel);
+ sentinel.remove();
}
_observer?.unobserve(sentinel);
});
@@ -114,10 +114,11 @@ function handleIntersection(entries: IntersectionObserverEntry[]): void {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
- const milestoneStr = entry.target.getAttribute('data-analytics-sentinel');
+ const milestoneStr = (entry.target as HTMLElement).dataset
+ .analyticsSentinel;
if (!milestoneStr) return;
- const milestone = parseInt(milestoneStr, 10) as Milestone;
+ const milestone = Number.parseInt(milestoneStr, 10) as Milestone;
// Fire each milestone exactly once per page view
if (_milestonesReached.has(milestone)) return;
diff --git a/packages/core/src/autocapture/time-on-page.test.ts b/packages/core/src/autocapture/time-on-page.test.ts
index 3c6e399..6350346 100644
--- a/packages/core/src/autocapture/time-on-page.test.ts
+++ b/packages/core/src/autocapture/time-on-page.test.ts
@@ -29,7 +29,7 @@ afterEach(() => {
describe('Time on page tracking', () => {
it('fires page_leave event on page hide', () => {
vi.advanceTimersByTime(5000);
- window.dispatchEvent(new Event('pagehide'));
+ globalThis.dispatchEvent(new Event('pagehide'));
expect(mockTrack).toHaveBeenCalledWith(
'page_leave',
@@ -53,7 +53,7 @@ describe('Time on page tracking', () => {
vi.advanceTimersByTime(2000);
- window.dispatchEvent(new Event('pagehide'));
+ globalThis.dispatchEvent(new Event('pagehide'));
const props = mockTrack.mock.calls[0][1];
// Should be ~5000ms not ~15000ms
@@ -63,7 +63,7 @@ describe('Time on page tracking', () => {
it('does not fire for very short visits under 1 second', () => {
vi.advanceTimersByTime(500);
- window.dispatchEvent(new Event('pagehide'));
+ globalThis.dispatchEvent(new Event('pagehide'));
expect(mockTrack).not.toHaveBeenCalled();
});
@@ -73,7 +73,7 @@ describe('Time on page tracking', () => {
// Fire pagehide to accumulate the 5000ms (calls accumulateActiveTime + flushActiveTime)
// Note: flushActiveTime will fire the event and reset _activeTime back to 0
- window.dispatchEvent(new Event('pagehide'));
+ globalThis.dispatchEvent(new Event('pagehide'));
// Should have fired for the previous page
expect(mockTrack).toHaveBeenCalledTimes(1);
@@ -88,7 +88,7 @@ describe('Time on page tracking', () => {
// Fresh timer for new page
vi.advanceTimersByTime(3000);
- window.dispatchEvent(new Event('pagehide'));
+ globalThis.dispatchEvent(new Event('pagehide'));
const props = mockTrack.mock.calls[0][1];
expect(props.duration).toBeLessThan(4000);
diff --git a/packages/core/src/autocapture/time-on-page.ts b/packages/core/src/autocapture/time-on-page.ts
index 7b6e859..5929bb5 100644
--- a/packages/core/src/autocapture/time-on-page.ts
+++ b/packages/core/src/autocapture/time-on-page.ts
@@ -32,7 +32,7 @@ export function attachTimeOnPageTracker(opts: TimeOnPageOptions): void {
_onPageHide = handlePageHide;
document.addEventListener('visibilitychange', _onVisibilityChange);
- window.addEventListener('pagehide', _onPageHide);
+ globalThis.addEventListener('pagehide', _onPageHide);
}
export function detachTimeOnPageTracker(): void {
@@ -45,7 +45,7 @@ export function detachTimeOnPageTracker(): void {
}
if (_onPageHide !== null) {
- window.removeEventListener('pagehide', _onPageHide);
+ globalThis.removeEventListener('pagehide', _onPageHide);
_onPageHide = null;
}
diff --git a/packages/core/src/config/device.ts b/packages/core/src/config/device.ts
index 3dfcb77..8e1b48a 100644
--- a/packages/core/src/config/device.ts
+++ b/packages/core/src/config/device.ts
@@ -2,7 +2,11 @@ export type DeviceType = 'desktop' | 'mobile' | 'tablet' | 'unknown';
function readUserAgent(): string {
try {
- return typeof navigator !== 'undefined' ? navigator.userAgent || '' : '';
+ if (typeof navigator === 'undefined') {
+ return '';
+ }
+
+ return navigator.userAgent || '';
} catch {
return '';
}
diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts
index 95196de..c6fdae9 100644
--- a/packages/core/src/config/index.ts
+++ b/packages/core/src/config/index.ts
@@ -2,7 +2,7 @@ import { validate } from './validator';
import { merge } from './merger';
import type { AnalyticsConfig, ResolvedConfig } from './types';
-export type { AnalyticsConfig, ResolvedConfig };
+export type { AnalyticsConfig, ResolvedConfig } from './types';
/**
* The single function the Tracker calls.
diff --git a/packages/core/src/config/validator.ts b/packages/core/src/config/validator.ts
index 75a9b44..e7f9608 100644
--- a/packages/core/src/config/validator.ts
+++ b/packages/core/src/config/validator.ts
@@ -1,8 +1,20 @@
import type { AnalyticsConfig } from './types';
export function validate(config: AnalyticsConfig): void {
- // siteToken
+ validateSiteToken(config);
+ validateEndpoint(config);
+ validateFlushInterval(config);
+ validateMaxBatchSize(config);
+ validateMaxQueueSize(config);
+ validateBooleanField(config, 'compress');
+ validateBooleanField(config, 'debug');
+ validateBooleanField(config, 'requireConsent');
+ validateBooleanField(config, 'respectDoNotTrack');
+ validateAutocapture(config);
+ validateAdapters(config);
+}
+function validateSiteToken(config: AnalyticsConfig): void {
if (!config.siteToken || typeof config.siteToken !== 'string') {
throw new Error(
'[AnalyticsSDK] siteToken is required.\n' +
@@ -24,178 +36,171 @@ export function validate(config: AnalyticsConfig): void {
'The ingestion schema limits site_id to 64 characters.',
);
}
+}
- // endpoint
-
- if (config.endpoint !== undefined) {
- let parsed: URL;
-
- try {
- parsed = new URL(config.endpoint);
- } catch {
- throw new Error(
- `[AnalyticsSDK] endpoint "${config.endpoint}" is not a valid URL.\n` +
- 'Example: endpoint: "https://ingest.your-instance.com/api/v1/events"',
- );
- }
-
- const isLocalhost =
- parsed.hostname === 'localhost' ||
- parsed.hostname === '127.0.0.1' ||
- parsed.hostname.endsWith('.local');
-
- if (parsed.protocol !== 'https:' && !isLocalhost) {
- throw new Error(
- `[AnalyticsSDK] endpoint must use HTTPS. Received: "${config.endpoint}"\n` +
- 'HTTP is only allowed for localhost during development.',
- );
- }
+function validateEndpoint(config: AnalyticsConfig): void {
+ if (config.endpoint === undefined) {
+ return;
}
- // flushInterval
+ let parsed: URL;
- if (config.flushInterval !== undefined) {
- if (
- typeof config.flushInterval !== 'number' ||
- !Number.isInteger(config.flushInterval) ||
- config.flushInterval < 500
- ) {
- throw new Error(
- `[AnalyticsSDK] flushInterval must be an integer >= 500ms.\n` +
- `Received: ${config.flushInterval}`,
- );
- }
+ try {
+ parsed = new URL(config.endpoint);
+ } catch {
+ throw new Error(
+ `[AnalyticsSDK] endpoint "${config.endpoint}" is not a valid URL.\n` +
+ 'Example: endpoint: "https://ingest.your-instance.com/api/v1/events"',
+ );
}
- // maxBatchSize
+ const isLocalhost =
+ parsed.hostname === 'localhost' ||
+ parsed.hostname === '127.0.0.1' ||
+ parsed.hostname.endsWith('.local');
- if (config.maxBatchSize !== undefined) {
- if (
- typeof config.maxBatchSize !== 'number' ||
- !Number.isInteger(config.maxBatchSize) ||
- config.maxBatchSize < 1 ||
- config.maxBatchSize > 200
- ) {
- throw new Error(
- `[AnalyticsSDK] maxBatchSize must be an integer between 1 and 200.\n` +
- `Received: ${config.maxBatchSize}`,
- );
- }
+ if (parsed.protocol !== 'https:' && !isLocalhost) {
+ throw new Error(
+ `[AnalyticsSDK] endpoint must use HTTPS. Received: "${config.endpoint}"\n` +
+ 'HTTP is only allowed for localhost during development.',
+ );
}
+}
- // maxQueueSize
- if (config.maxQueueSize !== undefined) {
- if (
- typeof config.maxQueueSize !== 'number' ||
- !Number.isInteger(config.maxQueueSize) ||
- config.maxQueueSize < 1 ||
- config.maxQueueSize > 10000
- ) {
- throw new Error(
- `[AnalyticsSDK] maxQueueSize must be an integer between 1 and 10000.\n` +
- `Received: ${config.maxQueueSize}`,
- );
- }
+function validateFlushInterval(config: AnalyticsConfig): void {
+ if (config.flushInterval === undefined) {
+ return;
}
- // compress
- if (config.compress !== undefined && typeof config.compress !== 'boolean') {
+ if (
+ typeof config.flushInterval !== 'number' ||
+ !Number.isInteger(config.flushInterval) ||
+ config.flushInterval < 500
+ ) {
throw new Error(
- `[AnalyticsSDK] compress must be a boolean.\n` +
- `Received: ${config.compress}`,
+ `[AnalyticsSDK] flushInterval must be an integer >= 500ms.\n` +
+ `Received: ${config.flushInterval}`,
);
}
+}
- // debug
- if (config.debug !== undefined && typeof config.debug !== 'boolean') {
- throw new Error(
- `[AnalyticsSDK] debug must be a boolean.\n` + `Received: ${config.debug}`,
- );
+function validateMaxBatchSize(config: AnalyticsConfig): void {
+ if (config.maxBatchSize === undefined) {
+ return;
}
- // requireConsent
if (
- config.requireConsent !== undefined &&
- typeof config.requireConsent !== 'boolean'
+ typeof config.maxBatchSize !== 'number' ||
+ !Number.isInteger(config.maxBatchSize) ||
+ config.maxBatchSize < 1 ||
+ config.maxBatchSize > 200
) {
throw new Error(
- `[AnalyticsSDK] requireConsent must be a boolean.\n` +
- `Received: ${config.requireConsent}`,
+ `[AnalyticsSDK] maxBatchSize must be an integer between 1 and 200.\n` +
+ `Received: ${config.maxBatchSize}`,
);
}
- // respectDoNotTrack
+}
+
+function validateMaxQueueSize(config: AnalyticsConfig): void {
+ if (config.maxQueueSize === undefined) {
+ return;
+ }
+
if (
- config.respectDoNotTrack !== undefined &&
- typeof config.respectDoNotTrack !== 'boolean'
+ typeof config.maxQueueSize !== 'number' ||
+ !Number.isInteger(config.maxQueueSize) ||
+ config.maxQueueSize < 1 ||
+ config.maxQueueSize > 10000
) {
throw new Error(
- `[AnalyticsSDK] respectDoNotTrack must be a boolean.\n` +
- `Received: ${config.respectDoNotTrack}`,
+ `[AnalyticsSDK] maxQueueSize must be an integer between 1 and 10000.\n` +
+ `Received: ${config.maxQueueSize}`,
);
}
+}
+
+type BooleanConfigKey =
+ | 'compress'
+ | 'debug'
+ | 'requireConsent'
+ | 'respectDoNotTrack';
+
+function validateBooleanField(
+ config: AnalyticsConfig,
+ field: BooleanConfigKey,
+): void {
+ const value = config[field];
- // autocapture
- if (config.autocapture !== undefined) {
- if (typeof config.autocapture === 'boolean') {
- // valid
- } else {
- if (
- typeof config.autocapture !== 'object' ||
- config.autocapture === null
- ) {
- throw new Error(
- `[AnalyticsSDK] autocapture must be a boolean or an object.\n` +
- `Received: ${config.autocapture}`,
- );
- }
-
- for (const [key, value] of Object.entries(config.autocapture ?? {})) {
- if (typeof value !== 'boolean') {
- throw new Error(
- `[AnalyticsSDK] autocapture.${key} must be a boolean.\n` +
- `Received: ${value}`,
- );
- }
- }
+ if (value !== undefined && typeof value !== 'boolean') {
+ throw new Error(
+ `[AnalyticsSDK] ${field} must be a boolean.\n` + `Received: ${value}`,
+ );
+ }
+}
+
+function validateAutocapture(config: AnalyticsConfig): void {
+ if (config.autocapture === undefined) {
+ return;
+ }
+
+ if (typeof config.autocapture === 'boolean') {
+ return;
+ }
+
+ if (typeof config.autocapture !== 'object' || config.autocapture === null) {
+ throw new Error(
+ `[AnalyticsSDK] autocapture must be a boolean or an object.\n` +
+ `Received: ${config.autocapture}`,
+ );
+ }
+
+ for (const [key, value] of Object.entries(config.autocapture)) {
+ if (typeof value !== 'boolean') {
+ throw new TypeError(
+ `[AnalyticsSDK] autocapture.${key} must be a boolean.\n` +
+ `Received: ${value}`,
+ );
}
}
+}
- if (config.adapters !== undefined) {
- if (!Array.isArray(config.adapters)) {
+function validateAdapters(config: AnalyticsConfig): void {
+ if (config.adapters === undefined) {
+ return;
+ }
+
+ if (!Array.isArray(config.adapters)) {
+ throw new TypeError(
+ `[AnalyticsSDK] adapters must be an array.\n` +
+ `Received: ${config.adapters}`,
+ );
+ }
+
+ for (const [index, adapter] of config.adapters.entries()) {
+ if (typeof adapter !== 'object' || adapter === null) {
+ throw new Error(
+ `[AnalyticsSDK] adapters[${index}] must be an object.\n` +
+ `Received: ${adapter}`,
+ );
+ }
+
+ if (typeof adapter.name !== 'string' || adapter.name.trim().length === 0) {
+ throw new Error(
+ `[AnalyticsSDK] adapters[${index}].name must be a non-empty string.`,
+ );
+ }
+
+ if (typeof adapter.attach !== 'function') {
throw new TypeError(
- `[AnalyticsSDK] adapters must be an array.\n` +
- `Received: ${config.adapters}`,
+ `[AnalyticsSDK] adapters[${index}].attach must be a function.`,
);
}
- for (const [index, adapter] of config.adapters.entries()) {
- if (typeof adapter !== 'object' || adapter === null) {
- throw new Error(
- `[AnalyticsSDK] adapters[${index}] must be an object.\n` +
- `Received: ${adapter}`,
- );
- }
-
- if (
- typeof adapter.name !== 'string' ||
- adapter.name.trim().length === 0
- ) {
- throw new Error(
- `[AnalyticsSDK] adapters[${index}].name must be a non-empty string.`,
- );
- }
-
- if (typeof adapter.attach !== 'function') {
- throw new TypeError(
- `[AnalyticsSDK] adapters[${index}].attach must be a function.`,
- );
- }
-
- if (typeof adapter.detach !== 'function') {
- throw new TypeError(
- `[AnalyticsSDK] adapters[${index}].detach must be a function.`,
- );
- }
+ if (typeof adapter.detach !== 'function') {
+ throw new TypeError(
+ `[AnalyticsSDK] adapters[${index}].detach must be a function.`,
+ );
}
}
}
diff --git a/packages/core/src/events/event-builder.test.ts b/packages/core/src/events/event-builder.test.ts
index d578662..8d070fc 100644
--- a/packages/core/src/events/event-builder.test.ts
+++ b/packages/core/src/events/event-builder.test.ts
@@ -13,6 +13,10 @@ import {
import { EventTypes } from './event-types';
function stubBrowserGlobals() {
+ vi.stubGlobal('location', {
+ href: 'https://example.com/path?a=1&utm_source=news&utm_medium=email&utm_campaign=spring#cta',
+ });
+
vi.stubGlobal('window', {
location: {
href: 'https://example.com/path?a=1&utm_source=news&utm_medium=email&utm_campaign=spring#cta',
@@ -131,6 +135,7 @@ describe('events/event-builder', () => {
});
it('falls back to safe defaults when browser globals are unavailable', () => {
+ vi.stubGlobal('location', undefined);
vi.stubGlobal('window', undefined);
vi.stubGlobal('document', undefined);
vi.stubGlobal('navigator', undefined);
diff --git a/packages/core/src/events/event-builder.ts b/packages/core/src/events/event-builder.ts
index deef265..98e3546 100644
--- a/packages/core/src/events/event-builder.ts
+++ b/packages/core/src/events/event-builder.ts
@@ -93,7 +93,7 @@ function attachProperties(
function readUrl(): string {
try {
- const url = new URL(window.location.href);
+ const url = new URL(globalThis.location.href);
// clean up the url to return only canonical URL string
url.search = '';
@@ -119,7 +119,7 @@ function readUtmParameters(): {
utmCampaign?: string;
} {
try {
- const params = new URL(window.location.href).searchParams;
+ const params = new URL(globalThis.location.href).searchParams;
return {
utmSource: params.get('utm_source') ?? undefined,
diff --git a/packages/core/src/tracker.test.ts b/packages/core/src/tracker.test.ts
index 36fc00d..79c72fc 100644
--- a/packages/core/src/tracker.test.ts
+++ b/packages/core/src/tracker.test.ts
@@ -14,6 +14,7 @@ function stubBrowserEnv({
referrer?: string;
eventId?: string;
}): void {
+ vi.stubGlobal('location', { href });
vi.stubGlobal('window', {
location: { href },
screen: { width: 1920, height: 1080 },
diff --git a/packages/core/src/tracker.ts b/packages/core/src/tracker.ts
index e936593..4ce9a26 100644
--- a/packages/core/src/tracker.ts
+++ b/packages/core/src/tracker.ts
@@ -5,9 +5,8 @@ import type {
MediaAdapterInterface,
MediaEventType,
} from './types';
-import type { AnalyticsConfig } from './config/types';
+import type { AnalyticsConfig, ResolvedConfig } from './config/types';
import { validateAndMergeConfig } from './config';
-import type { ResolvedConfig } from './config/types';
import { configurePrivacy, isTrackingAllowed } from './privacy';
import { SessionManager } from './session';
import {
diff --git a/packages/core/src/transport/queue.ts b/packages/core/src/transport/queue.ts
index 6445d00..fee5ebb 100644
--- a/packages/core/src/transport/queue.ts
+++ b/packages/core/src/transport/queue.ts
@@ -105,7 +105,7 @@ export class EventQueue {
// Page unload handling
private handleVisibilityChange(): void {
- if (typeof document === 'undefined') return;
+ if (globalThis.document === undefined) return;
// Fire when tab becomes hidden — user switched tabs or minimised
if (document.visibilityState === 'hidden') {
@@ -142,21 +142,21 @@ export class EventQueue {
}
private attachUnloadListeners(): void {
- if (typeof document === 'undefined' || typeof window === 'undefined') {
+ if (globalThis.document === undefined || globalThis.window === undefined) {
return;
}
document.addEventListener('visibilitychange', this.onVisibilityChange);
- window.addEventListener('pagehide', this.onPageHide);
+ globalThis.window.addEventListener('pagehide', this.onPageHide);
}
private detachUnloadListeners(): void {
- if (typeof document === 'undefined' || typeof window === 'undefined') {
+ if (globalThis.document === undefined || globalThis.window === undefined) {
return;
}
document.removeEventListener('visibilitychange', this.onVisibilityChange);
- window.removeEventListener('pagehide', this.onPageHide);
+ globalThis.window.removeEventListener('pagehide', this.onPageHide);
}
private takeNextBatch(): QueuedEvent[] {