diff --git a/packages/browser-core/src/browser/cookie.ts b/packages/browser-core/src/browser/cookie.ts index 3851782b36..982205feca 100644 --- a/packages/browser-core/src/browser/cookie.ts +++ b/packages/browser-core/src/browser/cookie.ts @@ -1,10 +1,9 @@ import { ONE_SECOND } from '@datadog/js-core/time' -import { buildUrl, globalObject } from '@datadog/js-core/util' +import { buildUrl, globalObject, generateUUID } from '@datadog/js-core/util' import { findAllCommaSeparatedValues, findCommaSeparatedValue, findCommaSeparatedValues, - generateUUID, } from '../tools/utils/stringUtils' export interface CookieOptions { diff --git a/packages/browser-core/src/browser/cookieAccess.ts b/packages/browser-core/src/browser/cookieAccess.ts index 983b2a7829..c91803eb66 100644 --- a/packages/browser-core/src/browser/cookieAccess.ts +++ b/packages/browser-core/src/browser/cookieAccess.ts @@ -1,10 +1,9 @@ import { ONE_MINUTE, ONE_SECOND, dateNow } from '@datadog/js-core/time' -import { globalObject } from '@datadog/js-core/util' +import { globalObject, generateUUID } from '@datadog/js-core/util' import { setInterval, clearInterval } from '../tools/timer' import { Observable } from '../tools/observable' import { mockable } from '../tools/mockable' import { display } from '../tools/display' -import { generateUUID } from '../tools/utils/stringUtils' import { addTelemetryDebug } from '../domain/telemetry' import { addEventListener, DOM_EVENT, isEventSupported } from './addEventListener' import { getCookies, setCookie } from './cookie' diff --git a/packages/browser-core/src/domain/console/consoleObservable.ts b/packages/browser-core/src/domain/console/consoleObservable.ts index c9802c46c1..328fca77fa 100644 --- a/packages/browser-core/src/domain/console/consoleObservable.ts +++ b/packages/browser-core/src/domain/console/consoleObservable.ts @@ -1,10 +1,9 @@ import { clocksNow } from '@datadog/js-core/time' -import { ConsoleApiName, globalConsole } from '@datadog/js-core/util' +import { ConsoleApiName, globalConsole, jsonStringify } from '@datadog/js-core/util' import { isError, computeRawError } from '../error/error' import { Observable, mergeObservables } from '../../tools/observable' import { callMonitored } from '../../tools/monitor' import { sanitize } from '../../tools/serialisation/sanitize' -import { jsonStringify } from '../../tools/serialisation/jsonStringify' import type { RawError } from '../error/error.types' import { ErrorHandling, ErrorSource, NonErrorPrefix } from '../error/error.types' import { computeStackTrace } from '../../tools/stackTrace/computeStackTrace' diff --git a/packages/browser-core/src/domain/contexts/tabContext.ts b/packages/browser-core/src/domain/contexts/tabContext.ts index 985401528b..536d147bcc 100644 --- a/packages/browser-core/src/domain/contexts/tabContext.ts +++ b/packages/browser-core/src/domain/contexts/tabContext.ts @@ -1,5 +1,5 @@ import type { Hook } from '@datadog/js-core/assembly' -import { generateUUID } from '../../tools/utils/stringUtils' +import { generateUUID } from '@datadog/js-core/util' export const TAB_ID_STORAGE_KEY = '_dd_tab_id' diff --git a/packages/browser-core/src/domain/error/error.ts b/packages/browser-core/src/domain/error/error.ts index 719bcfc84e..88d123c479 100644 --- a/packages/browser-core/src/domain/error/error.ts +++ b/packages/browser-core/src/domain/error/error.ts @@ -1,8 +1,7 @@ import type { ClocksState } from '@datadog/js-core/time' -import { isIndexableObject } from '@datadog/js-core/util' +import { isIndexableObject, jsonStringify } from '@datadog/js-core/util' import { sanitize } from '../../tools/serialisation/sanitize' import type { Context } from '../../tools/serialisation/context' -import { jsonStringify } from '../../tools/serialisation/jsonStringify' import type { StackTrace } from '../../tools/stackTrace/computeStackTrace' import { computeStackTrace } from '../../tools/stackTrace/computeStackTrace' import { toStackTraceString } from '../../tools/stackTrace/handlingStack' diff --git a/packages/browser-core/src/domain/session/sessionManager.ts b/packages/browser-core/src/domain/session/sessionManager.ts index ff034f4cf2..cc1a590c0f 100644 --- a/packages/browser-core/src/domain/session/sessionManager.ts +++ b/packages/browser-core/src/domain/session/sessionManager.ts @@ -9,14 +9,13 @@ import { relativeNow, } from '@datadog/js-core/time' import type { TimeStamp, RelativeTime } from '@datadog/js-core/time' -import { isWorkerEnvironment } from '@datadog/js-core/util' +import { isWorkerEnvironment, generateUUID } from '@datadog/js-core/util' import { Observable } from '../../tools/observable' import { createValueHistory } from '../../tools/valueHistory' import { addEventListener, addEventListeners, DOM_EVENT } from '../../browser/addEventListener' import { clearInterval, clearTimeout, setInterval, setTimeout } from '../../tools/timer' import { mockable } from '../../tools/mockable' import { noop, throttle } from '../../tools/utils/functionUtils' -import { generateUUID } from '../../tools/utils/stringUtils' import type { Configuration } from '../configuration' import type { TrackingConsentState } from '../trackingConsent' import { display } from '../../tools/display' diff --git a/packages/browser-core/src/domain/session/sessionState.ts b/packages/browser-core/src/domain/session/sessionState.ts index daa35487aa..ef7bc1468f 100644 --- a/packages/browser-core/src/domain/session/sessionState.ts +++ b/packages/browser-core/src/domain/session/sessionState.ts @@ -1,8 +1,8 @@ import { dateNow } from '@datadog/js-core/time' import type { TimeStamp } from '@datadog/js-core/time' +import { generateUUID } from '@datadog/js-core/util' import { isEmptyObject } from '../../tools/utils/objectUtils' import { objectEntries } from '../../tools/utils/polyfills' -import { generateUUID } from '../../tools/utils/stringUtils' import type { Configuration } from '../configuration' import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from './sessionConstants' import { isValidSessionString, SESSION_ENTRY_REGEXP, SESSION_ENTRY_SEPARATOR } from './sessionStateValidation' diff --git a/packages/browser-core/src/domain/session/storeStrategies/sessionInLocalStorage.ts b/packages/browser-core/src/domain/session/storeStrategies/sessionInLocalStorage.ts index 4271107d43..84aba1e429 100644 --- a/packages/browser-core/src/domain/session/storeStrategies/sessionInLocalStorage.ts +++ b/packages/browser-core/src/domain/session/storeStrategies/sessionInLocalStorage.ts @@ -1,4 +1,4 @@ -import { generateUUID } from '../../../tools/utils/stringUtils' +import { generateUUID } from '@datadog/js-core/util' import { Observable } from '../../../tools/observable' import { addEventListener } from '../../../browser/addEventListener' import { SessionPersistence } from '../sessionConstants' diff --git a/packages/browser-core/src/domain/telemetry/telemetry.ts b/packages/browser-core/src/domain/telemetry/telemetry.ts index a3c780d462..1a22919249 100644 --- a/packages/browser-core/src/domain/telemetry/telemetry.ts +++ b/packages/browser-core/src/domain/telemetry/telemetry.ts @@ -1,5 +1,5 @@ import { clocksNow } from '@datadog/js-core/time' -import { getDebugMode, combine, globalObject, isWorkerEnvironment } from '@datadog/js-core/util' +import { getDebugMode, combine, globalObject, isWorkerEnvironment, jsonStringify } from '@datadog/js-core/util' import type { Hook } from '@datadog/js-core/assembly' import type { RecursivePartial } from '@datadog/js-core/util' import { DISCARDED } from '@datadog/js-core/assembly' @@ -21,7 +21,6 @@ import { startMonitorErrorCollection } from '../../tools/monitor' import { display } from '../../tools/display' import { sendToExtension } from '../../tools/sendToExtension' import { performDraw } from '../../tools/utils/numberUtils' -import { jsonStringify } from '../../tools/serialisation/jsonStringify' import { NonErrorPrefix } from '../error/error.types' import type { StackTrace } from '../../tools/stackTrace/computeStackTrace' import { computeStackTrace } from '../../tools/stackTrace/computeStackTrace' diff --git a/packages/browser-core/src/index.ts b/packages/browser-core/src/index.ts index efead8da41..b209e6d9da 100644 --- a/packages/browser-core/src/index.ts +++ b/packages/browser-core/src/index.ts @@ -167,11 +167,14 @@ export * from './tools/utils/polyfills' export * from './tools/utils/timezone' export * from './tools/utils/numberUtils' export * from './tools/utils/byteUtils' +export { ONE_KIBI_BYTE, ONE_MEBI_BYTE } from '@datadog/js-core/util' export * from './tools/utils/objectUtils' export * from './tools/utils/functionUtils' export * from './tools/serialisation/jsonStringify' +export { jsonStringify } from '@datadog/js-core/util' export * from './tools/serialisation/stringify' export * from './tools/utils/stringUtils' +export { generateUUID } from '@datadog/js-core/util' export * from './tools/matchOption' export * from './tools/utils/responseUtils' export type { RawError, RawErrorCause, ErrorWithCause, Csp } from './domain/error/error.types' diff --git a/packages/browser-core/src/tools/serialisation/jsonStringify.ts b/packages/browser-core/src/tools/serialisation/jsonStringify.ts index 031fe8a7ad..215f82ad83 100644 --- a/packages/browser-core/src/tools/serialisation/jsonStringify.ts +++ b/packages/browser-core/src/tools/serialisation/jsonStringify.ts @@ -1,41 +1,5 @@ import { noop } from '../utils/functionUtils' -/** - * Custom implementation of JSON.stringify that ignores some toJSON methods. We need to do that - * because some sites badly override toJSON on certain objects. Removing all toJSON methods from - * nested values would be too costly, so we just detach them from the root value, and native classes - * used to build JSON values (Array and Object). - * - * Note: this still assumes that JSON.stringify is correct. - */ -export function jsonStringify( - value: unknown, - replacer?: Array, - space?: string | number -): string | undefined { - if (typeof value !== 'object' || value === null) { - return JSON.stringify(value) - } - - // Note: The order matter here. We need to detach toJSON methods on parent classes before their - // subclasses. - const restoreObjectPrototypeToJson = detachToJsonMethod(Object.prototype) - const restoreArrayPrototypeToJson = detachToJsonMethod(Array.prototype) - const restoreValuePrototypeToJson = detachToJsonMethod(Object.getPrototypeOf(value)) - const restoreValueToJson = detachToJsonMethod(value) - - try { - return JSON.stringify(value, replacer, space) - } catch { - return '' - } finally { - restoreObjectPrototypeToJson() - restoreArrayPrototypeToJson() - restoreValuePrototypeToJson() - restoreValueToJson() - } -} - export interface ObjectWithToJsonMethod { toJSON?: () => unknown } diff --git a/packages/browser-core/src/tools/serialisation/sanitize.ts b/packages/browser-core/src/tools/serialisation/sanitize.ts index ae83e39b4f..85f0f5885c 100644 --- a/packages/browser-core/src/tools/serialisation/sanitize.ts +++ b/packages/browser-core/src/tools/serialisation/sanitize.ts @@ -1,5 +1,5 @@ +import { ONE_KIBI_BYTE } from '@datadog/js-core/util' import { display } from '../display' -import { ONE_KIBI_BYTE } from '../utils/byteUtils' import type { Context, ContextArray, ContextValue } from './context' import type { ObjectWithToJsonMethod } from './jsonStringify' import { detachToJsonMethod } from './jsonStringify' diff --git a/packages/browser-core/src/tools/serialisation/stringify.ts b/packages/browser-core/src/tools/serialisation/stringify.ts index 0f30f749f5..c1a151ecc2 100644 --- a/packages/browser-core/src/tools/serialisation/stringify.ts +++ b/packages/browser-core/src/tools/serialisation/stringify.ts @@ -1,4 +1,4 @@ -import { jsonStringify } from './jsonStringify' +import { jsonStringify } from '@datadog/js-core/util' import { sanitize } from './sanitize' export function safeToString(value: unknown): string | undefined { diff --git a/packages/browser-core/src/tools/timer.spec.ts b/packages/browser-core/src/tools/timer.spec.ts index 5ced697864..8341a1718c 100644 --- a/packages/browser-core/src/tools/timer.spec.ts +++ b/packages/browser-core/src/tools/timer.spec.ts @@ -24,14 +24,6 @@ import { noop } from './utils/functionUtils' zoneJs = mockZoneJs() }) - it('executes the callback asynchronously', () => { - const spy = jasmine.createSpy() - setTimer(spy) - expect(spy).not.toHaveBeenCalled() - clock.tick(0) - expect(spy).toHaveBeenCalledOnceWith() - }) - it('schedules an asynchronous task', () => { const spy = jasmine.createSpy() setTimer(spy) @@ -62,7 +54,7 @@ import { noop } from './utils/functionUtils' expect(onMonitorErrorCollectedSpy).toHaveBeenCalledOnceWith(new Error('foo')) }) - it('can be canceled', () => { + it('cancels the pending callback when cleared', () => { const spy = jasmine.createSpy() const timerId = setTimer(spy) clearTimer(timerId) diff --git a/packages/browser-core/src/tools/timer.ts b/packages/browser-core/src/tools/timer.ts index 13efb473ce..350165ecc9 100644 --- a/packages/browser-core/src/tools/timer.ts +++ b/packages/browser-core/src/tools/timer.ts @@ -1,22 +1,30 @@ -import type { GlobalObject } from '@datadog/js-core/util' -import { globalObject } from '@datadog/js-core/util' -import { getZoneJsOriginalValue } from './getZoneJsOriginalValue' +import type { TimeoutId } from '@datadog/js-core/util' +import { + setTimeout as coreSetTimeout, + clearTimeout as coreClearTimeout, + setInterval as coreSetInterval, + clearInterval as coreClearInterval, +} from '@datadog/js-core/util' import { monitor } from './monitor' -export type TimeoutId = ReturnType +export type { TimeoutId } + +// @datadog/js-core/util's timer functions already bypass Zone.js patching; we only need to add +// browser-core's monitor() wrapping on top, so errors thrown in the callback are still caught and +// reported instead of becoming uncaught exceptions. export function setTimeout(callback: () => void, delay?: number): TimeoutId { - return getZoneJsOriginalValue(globalObject, 'setTimeout')(monitor(callback), delay) + return coreSetTimeout(monitor(callback), delay) } export function clearTimeout(timeoutId: TimeoutId | undefined) { - getZoneJsOriginalValue(globalObject, 'clearTimeout')(timeoutId) + coreClearTimeout(timeoutId) } export function setInterval(callback: () => void, delay?: number): TimeoutId { - return getZoneJsOriginalValue(globalObject, 'setInterval')(monitor(callback), delay) + return coreSetInterval(monitor(callback), delay) } export function clearInterval(timeoutId: TimeoutId | undefined) { - getZoneJsOriginalValue(globalObject, 'clearInterval')(timeoutId) + coreClearInterval(timeoutId) } diff --git a/packages/browser-core/src/tools/utils/byteUtils.ts b/packages/browser-core/src/tools/utils/byteUtils.ts index 3f44f21eed..106027631c 100644 --- a/packages/browser-core/src/tools/utils/byteUtils.ts +++ b/packages/browser-core/src/tools/utils/byteUtils.ts @@ -1,6 +1,3 @@ -export const ONE_KIBI_BYTE = 1024 -export const ONE_MEBI_BYTE = 1024 * ONE_KIBI_BYTE - // eslint-disable-next-line no-control-regex const HAS_MULTI_BYTES_CHARACTERS = /[^\u0000-\u007F]/ diff --git a/packages/browser-core/src/tools/utils/stringUtils.ts b/packages/browser-core/src/tools/utils/stringUtils.ts index 4259c6d7c9..61491958b7 100644 --- a/packages/browser-core/src/tools/utils/stringUtils.ts +++ b/packages/browser-core/src/tools/utils/stringUtils.ts @@ -1,14 +1,3 @@ -/** - * UUID v4 - * from https://gist.github.com/jed/982883 - */ -export function generateUUID(placeholder?: string): string { - return placeholder - ? // eslint-disable-next-line no-bitwise - (parseInt(placeholder, 10) ^ ((Math.random() * 16) >> (parseInt(placeholder, 10) / 4))).toString(16) - : `${1e7}-${1e3}-${4e3}-${8e3}-${1e11}`.replace(/[018]/g, generateUUID) -} - // Assuming input string is following the HTTP Cookie format defined in // https://www.ietf.org/rfc/rfc2616.txt and https://www.ietf.org/rfc/rfc6265.txt, we don't need to // be too strict with this regex. diff --git a/packages/browser-core/src/transport/batch.ts b/packages/browser-core/src/transport/batch.ts index 99a79cbdac..0e8f8a6d09 100644 --- a/packages/browser-core/src/transport/batch.ts +++ b/packages/browser-core/src/transport/batch.ts @@ -1,12 +1,12 @@ import type { EndpointBuilder } from '@datadog/js-core/transport' +import { jsonStringify, ONE_KIBI_BYTE } from '@datadog/js-core/util' import { DOCS_TROUBLESHOOTING, MORE_DETAILS, display } from '../tools/display' import type { Context } from '../tools/serialisation/context' import { objectValues } from '../tools/utils/polyfills' import { isPageExitReason, createPageMayExitObservable } from '../browser/pageMayExitObservable' -import { jsonStringify } from '../tools/serialisation/jsonStringify' import { createIdentityEncoder } from '../tools/encoder' import type { Encoder, EncoderResult } from '../tools/encoder' -import { computeBytesCount, ONE_KIBI_BYTE } from '../tools/utils/byteUtils' +import { computeBytesCount } from '../tools/utils/byteUtils' import { mockable } from '../tools/mockable' import type { Observable } from '../tools/observable' import { createHttpRequest } from './httpRequest' diff --git a/packages/browser-core/src/transport/httpRequest.ts b/packages/browser-core/src/transport/httpRequest.ts index d7252b4091..b5c5887a15 100644 --- a/packages/browser-core/src/transport/httpRequest.ts +++ b/packages/browser-core/src/transport/httpRequest.ts @@ -1,9 +1,9 @@ import type { EndpointBuilder, TransportRetryInfo } from '@datadog/js-core/transport' +import { ONE_KIBI_BYTE } from '@datadog/js-core/util' import type { Context } from '../tools/serialisation/context' import { fetch } from '../browser/fetch' import { monitor, monitorError } from '../tools/monitor' import { Observable } from '../tools/observable' -import { ONE_KIBI_BYTE } from '../tools/utils/byteUtils' import { newRetryState, sendWithRetryStrategy } from './sendWithRetryStrategy' /** diff --git a/packages/browser-core/src/transport/sendWithRetryStrategy.spec.ts b/packages/browser-core/src/transport/sendWithRetryStrategy.spec.ts index f14cd74250..6432000b67 100644 --- a/packages/browser-core/src/transport/sendWithRetryStrategy.spec.ts +++ b/packages/browser-core/src/transport/sendWithRetryStrategy.spec.ts @@ -1,7 +1,7 @@ +import { ONE_MEBI_BYTE } from '@datadog/js-core/util' import { mockClock, setNavigatorOnLine } from '../../test' import type { Clock } from '../../test' import { Observable } from '../tools/observable' -import { ONE_MEBI_BYTE } from '../tools/utils/byteUtils' import type { RetryState } from './sendWithRetryStrategy' import { newRetryState, diff --git a/packages/browser-core/src/transport/sendWithRetryStrategy.ts b/packages/browser-core/src/transport/sendWithRetryStrategy.ts index 70488b29f3..8274dc5f9a 100644 --- a/packages/browser-core/src/transport/sendWithRetryStrategy.ts +++ b/packages/browser-core/src/transport/sendWithRetryStrategy.ts @@ -1,7 +1,7 @@ import { ONE_MINUTE, ONE_SECOND } from '@datadog/js-core/time' import type { TrackType } from '@datadog/js-core/transport' +import { ONE_MEBI_BYTE, ONE_KIBI_BYTE } from '@datadog/js-core/util' import { setTimeout } from '../tools/timer' -import { ONE_MEBI_BYTE, ONE_KIBI_BYTE } from '../tools/utils/byteUtils' import { isServerError } from '../tools/utils/responseUtils' import type { Observable } from '../tools/observable' import type { Payload, HttpRequestEvent, HttpResponse, BandwidthStats } from './httpRequest' diff --git a/packages/js-core/api/util.api.md b/packages/js-core/api/util.api.md index 32e2528ad6..0193480185 100644 --- a/packages/js-core/api/util.api.md +++ b/packages/js-core/api/util.api.md @@ -7,6 +7,14 @@ // @public export function buildUrl(url: string, base?: string): URL; +// @public +function clearInterval_2(timeoutId: TimeoutId | undefined): void; +export { clearInterval_2 as clearInterval } + +// @public +function clearTimeout_2(timeoutId: TimeoutId | undefined): void; +export { clearTimeout_2 as clearTimeout } + // @public export function combine(a: A, b: B): Combined; @@ -119,6 +127,9 @@ export interface Display { warn: typeof console.warn; } +// @public +export function generateUUID(placeholder?: string): string; + // @public export function getDebugMode(): boolean; @@ -155,6 +166,9 @@ export function isValidUrl(url: string): boolean; // @public export const isWorkerEnvironment: boolean; +// @public +export function jsonStringify(value: unknown, replacer?: Array, space?: string | number): string | undefined; + // @public export function mergeInto(destination: D, source: S): Merged; @@ -181,6 +195,12 @@ export type NetworkInterface = 'bluetooth' | 'cellular' | 'ethernet' | 'none' | // @public export function normalizeUrl(url: string): string; +// @public +export const ONE_KIBI_BYTE = 1024; + +// @public +export const ONE_MEBI_BYTE: number; + // @public export const originalConsoleMethods: Display; @@ -250,6 +270,26 @@ export interface SampleBufferFullEvent extends Event { // @public export function setDebugMode(newDebugMode: boolean): void; +// @public +function setInterval_2(callback: () => void, delay?: number): TimeoutId; +export { setInterval_2 as setInterval } + +// @public +function setTimeout_2(callback: () => void, delay?: number): TimeoutId; +export { setTimeout_2 as setTimeout } + +// @public +export function throttle void>(fn: T, wait: number, options?: { + leading?: boolean; + trailing?: boolean; +}): { + throttled: (...parameters: Parameters) => void; + cancel: () => void; +}; + +// @public +export type TimeoutId = ReturnType; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/js-core/src/entries/util.ts b/packages/js-core/src/entries/util.ts index f5446dc452..2d19731169 100644 --- a/packages/js-core/src/entries/util.ts +++ b/packages/js-core/src/entries/util.ts @@ -26,3 +26,9 @@ export type { ProfilerConstructor, } from '../util/globalObject' export { normalizeUrl, isValidUrl, getPathName, buildUrl, getPristineWindow } from '../util/urlPolyfill' +export { generateUUID } from '../util/stringUtils' +export { jsonStringify } from '../util/jsonStringify' +export { ONE_KIBI_BYTE, ONE_MEBI_BYTE } from '../util/byteUtils' +export { setTimeout, clearTimeout, setInterval, clearInterval } from '../util/timer' +export type { TimeoutId } from '../util/timer' +export { throttle } from '../util/functionUtils' diff --git a/packages/js-core/src/util/byteUtils.ts b/packages/js-core/src/util/byteUtils.ts new file mode 100644 index 0000000000..836c513007 --- /dev/null +++ b/packages/js-core/src/util/byteUtils.ts @@ -0,0 +1,5 @@ +/** Number of bytes in a kibibyte (1024 bytes). */ +export const ONE_KIBI_BYTE = 1024 + +/** Number of bytes in a mebibyte (1024 kibibytes). */ +export const ONE_MEBI_BYTE = 1024 * ONE_KIBI_BYTE diff --git a/packages/js-core/src/util/functionUtils.spec.ts b/packages/js-core/src/util/functionUtils.spec.ts new file mode 100644 index 0000000000..83eeee9b86 --- /dev/null +++ b/packages/js-core/src/util/functionUtils.spec.ts @@ -0,0 +1,116 @@ +import { throttle } from './functionUtils' + +describe('throttle', () => { + let spy: jasmine.Spy + let throttled: () => void + let cancel: () => void + + beforeEach(() => { + jasmine.clock().install() + spy = jasmine.createSpy() + }) + + afterEach(() => { + jasmine.clock().uninstall() + }) + + describe('when {leading: false, trailing:false}', () => { + beforeEach(() => { + throttled = throttle(spy, 2, { leading: false, trailing: false }).throttled + }) + + it('should not call throttled function', () => { + throttled() + expect(spy).toHaveBeenCalledTimes(0) + jasmine.clock().tick(2) + expect(spy).toHaveBeenCalledTimes(0) + }) + }) + + describe('when {leading: false, trailing:true}', () => { + beforeEach(() => { + throttled = throttle(spy, 2, { leading: false }).throttled + }) + + it('should call throttled function after the wait period', () => { + throttled() + expect(spy).toHaveBeenCalledTimes(0) + jasmine.clock().tick(2) + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should dismiss calls made during the wait period', () => { + throttled() + jasmine.clock().tick(1) + throttled() + jasmine.clock().tick(1) + expect(spy).toHaveBeenCalledTimes(1) + }) + }) + + describe('when {leading: true, trailing:false}', () => { + beforeEach(() => { + throttled = throttle(spy, 2, { trailing: false }).throttled + }) + + it('should call throttled function immediately', () => { + throttled() + expect(spy).toHaveBeenCalledTimes(1) + jasmine.clock().tick(2) + expect(spy).toHaveBeenCalledTimes(1) + }) + }) + + describe('when {leading: true, trailing:true} (default)', () => { + beforeEach(() => { + throttled = throttle(spy, 2).throttled + }) + + it('should call throttled function immediately, then once more for calls made during the wait period', () => { + throttled() + expect(spy).toHaveBeenCalledTimes(1) + + throttled() + expect(spy).toHaveBeenCalledTimes(1) + + jasmine.clock().tick(2) + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('passes last parameters as arguments', () => { + throttled = throttle(spy, 2).throttled + ;(throttled as (n: number) => void)(1) + ;(throttled as (n: number) => void)(2) + ;(throttled as (n: number) => void)(3) + jasmine.clock().tick(2) + expect(spy.calls.allArgs()).toEqual([[1], [3]]) + }) + }) + + describe('cancel', () => { + beforeEach(() => { + const result = throttle(spy, 2) + cancel = result.cancel + throttled = result.throttled + }) + + it('should abort pending execution', () => { + throttled() + throttled() + expect(spy).toHaveBeenCalledTimes(1) + + cancel() + + jasmine.clock().tick(2) + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should allow future calls', () => { + cancel() + throttled() + expect(spy).toHaveBeenCalledTimes(1) + jasmine.clock().tick(2) + expect(spy).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/js-core/src/util/functionUtils.ts b/packages/js-core/src/util/functionUtils.ts new file mode 100644 index 0000000000..cfdff7ca32 --- /dev/null +++ b/packages/js-core/src/util/functionUtils.ts @@ -0,0 +1,55 @@ +import type { TimeoutId } from './timer' +import { setTimeout, clearTimeout } from './timer' + +/** + * Wraps a function so it is invoked at most once per `wait` period, regardless of how many times + * the returned `throttled` function is called during that period. Mirrors lodash's `throttle` API. + * + * @param fn - The function to throttle. + * @param wait - The minimum delay, in milliseconds, between two invocations of `fn`. + * @param options - Throttle behavior options. + * @param options.leading - Invokes `fn` immediately on the first call of a throttle window. + * Defaults to `true`. + * @param options.trailing - Invokes `fn` once more at the end of the window if calls happened + * during it. Defaults to `true`. + * @returns An object with `throttled`, the throttled function to call instead of `fn`, and + * `cancel`, which cancels any pending trailing invocation and resets the throttle window. + */ +export function throttle void>( + fn: T, + wait: number, + options?: { leading?: boolean; trailing?: boolean } +) { + const needLeadingExecution = options?.leading !== undefined ? options.leading : true + const needTrailingExecution = options?.trailing !== undefined ? options.trailing : true + let inWaitPeriod = false + let pendingExecutionWithParameters: Parameters | undefined + let pendingTimeoutId: TimeoutId + + return { + throttled: (...parameters: Parameters) => { + if (inWaitPeriod) { + pendingExecutionWithParameters = parameters + return + } + if (needLeadingExecution) { + fn(...parameters) + } else { + pendingExecutionWithParameters = parameters + } + inWaitPeriod = true + pendingTimeoutId = setTimeout(() => { + if (needTrailingExecution && pendingExecutionWithParameters) { + fn(...pendingExecutionWithParameters) + } + inWaitPeriod = false + pendingExecutionWithParameters = undefined + }, wait) + }, + cancel: () => { + clearTimeout(pendingTimeoutId) + inWaitPeriod = false + pendingExecutionWithParameters = undefined + }, + } +} diff --git a/packages/browser-core/src/tools/serialisation/jsonStringify.spec.ts b/packages/js-core/src/util/jsonStringify.spec.ts similarity index 100% rename from packages/browser-core/src/tools/serialisation/jsonStringify.spec.ts rename to packages/js-core/src/util/jsonStringify.spec.ts diff --git a/packages/js-core/src/util/jsonStringify.ts b/packages/js-core/src/util/jsonStringify.ts new file mode 100644 index 0000000000..dd1dc40653 --- /dev/null +++ b/packages/js-core/src/util/jsonStringify.ts @@ -0,0 +1,68 @@ +interface ObjectWithToJsonMethod { + toJSON?: () => unknown +} + +function noop() { + // do nothing +} + +/** + * Temporarily removes a `toJSON` method from an object, if it has one, so that `JSON.stringify` + * serializes its own enumerable properties instead of delegating to a custom `toJSON` + * implementation (e.g. `Error.prototype.toJSON` added by some libraries). + * + * @param value - The object to detach the `toJSON` method from. + * @returns A function that restores the original `toJSON` method (or does nothing if the object + * did not have one). + */ +function detachToJsonMethod(value: object) { + const object = value as ObjectWithToJsonMethod + const objectToJson = object.toJSON + if (objectToJson) { + delete object.toJSON + return () => { + object.toJSON = objectToJson + } + } + return noop +} + +/** + * Serializes a value to a JSON string, tolerating values that would normally make + * `JSON.stringify` throw (circular references, `BigInt`, ...) or produce misleading output (values + * with a custom `toJSON` method, e.g. some `Error` polyfills). + * + * Prefer this over `JSON.stringify` directly when serializing values of unknown origin (for + * example, formatting an arbitrary non-`Error` value caught in an error handler). + * + * @param value - The value to serialize. + * @param replacer - Same as `JSON.stringify`'s `replacer` parameter. + * @param space - Same as `JSON.stringify`'s `space` parameter. + * @returns The JSON string, `undefined` if `value` is `undefined` (or a function/symbol), or the + * string `""` if serialization still fails. + */ +export function jsonStringify( + value: unknown, + replacer?: Array, + space?: string | number +): string | undefined { + if (typeof value !== 'object' || value === null) { + return JSON.stringify(value) + } + + const restoreObjectPrototypeToJson = detachToJsonMethod(Object.prototype) + const restoreArrayPrototypeToJson = detachToJsonMethod(Array.prototype) + const restoreValuePrototypeToJson = detachToJsonMethod(Object.getPrototypeOf(value) as object) + const restoreValueToJson = detachToJsonMethod(value) + + try { + return JSON.stringify(value, replacer, space) + } catch { + return '' + } finally { + restoreObjectPrototypeToJson() + restoreArrayPrototypeToJson() + restoreValuePrototypeToJson() + restoreValueToJson() + } +} diff --git a/packages/js-core/src/util/stringUtils.spec.ts b/packages/js-core/src/util/stringUtils.spec.ts new file mode 100644 index 0000000000..8672aadf01 --- /dev/null +++ b/packages/js-core/src/util/stringUtils.spec.ts @@ -0,0 +1,13 @@ +import { generateUUID } from './stringUtils' + +describe('generateUUID', () => { + const UUID_V4_REGEXP = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + + it('generates a valid UUID v4', () => { + expect(generateUUID()).toMatch(UUID_V4_REGEXP) + }) + + it('generates different UUIDs on each call', () => { + expect(generateUUID()).not.toBe(generateUUID()) + }) +}) diff --git a/packages/js-core/src/util/stringUtils.ts b/packages/js-core/src/util/stringUtils.ts new file mode 100644 index 0000000000..7eee89c813 --- /dev/null +++ b/packages/js-core/src/util/stringUtils.ts @@ -0,0 +1,19 @@ +/** + * Generates a random UUID v4 string. + * + * Used to identify entities (sessions, views, errors, operations, ...) with an identifier that is + * unique enough for telemetry purposes, without relying on `crypto.randomUUID` which is not + * available in every environment (e.g. non-secure contexts). + * + * Adapted from https://gist.github.com/jed/982883 + * + * @param placeholder - Internal recursion parameter; omit it, it is only used by the function + * itself while building the UUID. + * @returns A UUID v4 string, e.g. `"110ec58a-a0f2-4ac4-8393-c866d813b8d1"`. + */ +export function generateUUID(placeholder?: string): string { + return placeholder + ? // eslint-disable-next-line no-bitwise + (parseInt(placeholder, 10) ^ ((Math.random() * 16) >> (parseInt(placeholder, 10) / 4))).toString(16) + : `${1e7}-${1e3}-${4e3}-${8e3}-${1e11}`.replace(/[018]/g, generateUUID) +} diff --git a/packages/js-core/src/util/timer.spec.ts b/packages/js-core/src/util/timer.spec.ts new file mode 100644 index 0000000000..6ec3cab4f6 --- /dev/null +++ b/packages/js-core/src/util/timer.spec.ts @@ -0,0 +1,39 @@ +import { setTimeout, clearTimeout, setInterval, clearInterval } from './timer' +;[ + { + name: 'setTimeout' as const, + setTimer: setTimeout, + clearTimer: clearTimeout, + }, + { + name: 'setInterval' as const, + setTimer: setInterval, + clearTimer: clearInterval, + }, +].forEach(({ name, setTimer, clearTimer }) => { + describe(name, () => { + beforeEach(() => { + jasmine.clock().install() + }) + + afterEach(() => { + jasmine.clock().uninstall() + }) + + it('executes the callback asynchronously', () => { + const spy = jasmine.createSpy() + setTimer(spy) + expect(spy).not.toHaveBeenCalled() + jasmine.clock().tick(0) + expect(spy).toHaveBeenCalledOnceWith() + }) + + it('can be canceled', () => { + const spy = jasmine.createSpy() + const timerId = setTimer(spy) + clearTimer(timerId) + jasmine.clock().tick(0) + expect(spy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/js-core/src/util/timer.ts b/packages/js-core/src/util/timer.ts new file mode 100644 index 0000000000..ae917cdc6c --- /dev/null +++ b/packages/js-core/src/util/timer.ts @@ -0,0 +1,85 @@ +import type { GlobalObject } from './globalObject' +import { globalObject } from './globalObject' + +interface GlobalObjectWithZoneJs { + Zone?: { + // All Zone.js versions expose the __symbol__ method, but we observed that some website have a + // 'Zone' global variable unrelated to Zone.js, so let's consider this method optional + // nonetheless. + __symbol__?: (name: string) => string + } +} + +/** + * Gets the original value for a global API that was potentially patched by Zone.js. + * + * Zone.js (used by Angular) patches a bunch of JS and DOM APIs, including timers, and stores the + * original value of the patched functions in a hidden property prefixed by `__zone_symbol__`. + * Using the patched `setTimeout` has been observed to trigger rendering loops in some Angular + * applications, so this helper is used to bypass the patch. In environments without Zone.js + * (including Node.js), the `Zone` global is simply absent and this falls back to the plain + * property. + * + * @param target - The object the API is read from (usually the global object). + * @param name - The property name to read. + * @returns The original, unpatched value of `target[name]`. + */ +function getZoneJsOriginalValue(target: Target, name: Name): Target[Name] { + const targetWithZoneJs = target as Target & GlobalObjectWithZoneJs + let original: Target[Name] | undefined + if (targetWithZoneJs.Zone && typeof targetWithZoneJs.Zone.__symbol__ === 'function') { + original = (target as any)[targetWithZoneJs.Zone.__symbol__(name)] + } + if (!original) { + original = target[name] + } + return original +} + +/** Identifier returned by {@link setTimeout}/{@link setInterval}, to be passed to their `clear*` counterpart. */ +export type TimeoutId = ReturnType + +/** + * Equivalent to the native `setTimeout`, but bypasses patches applied by Zone.js (used by Angular) + * that have been observed to cause rendering loops. Prefer this over the native `setTimeout` in + * any code that might run in an Angular application. + * + * @param callback - The function to invoke after `delay` milliseconds. + * @param delay - The delay in milliseconds, defaults to the platform's default (usually `0`). + * @returns A {@link TimeoutId} to pass to {@link clearTimeout}. + */ +export function setTimeout(callback: () => void, delay?: number): TimeoutId { + return getZoneJsOriginalValue(globalObject, 'setTimeout')(callback, delay) +} + +/** + * Equivalent to the native `clearTimeout`, but bypasses patches applied by Zone.js. See + * {@link setTimeout} for more details. + * + * @param timeoutId - The {@link TimeoutId} returned by a previous {@link setTimeout} call. + */ +export function clearTimeout(timeoutId: TimeoutId | undefined) { + getZoneJsOriginalValue(globalObject, 'clearTimeout')(timeoutId) +} + +/** + * Equivalent to the native `setInterval`, but bypasses patches applied by Zone.js. See + * {@link setTimeout} for more details. + * + * @param callback - The function to invoke every `delay` milliseconds. + * @param delay - The delay in milliseconds, defaults to the platform's default (usually `0`). + * @returns A {@link TimeoutId} to pass to {@link clearInterval}. + */ +export function setInterval(callback: () => void, delay?: number): TimeoutId { + return getZoneJsOriginalValue(globalObject, 'setInterval')(callback, delay) +} + +/** + * Equivalent to the native `clearInterval`, but bypasses patches applied by Zone.js. See + * {@link setTimeout} for more details. + * + * @param timeoutId - The {@link TimeoutId} returned by a previous {@link setInterval} call. + */ +export function clearInterval(timeoutId: TimeoutId | undefined) { + getZoneJsOriginalValue(globalObject, 'clearInterval')(timeoutId) +}