Skip to content

Commit

Permalink
fix: angular change detection clashing
Browse files Browse the repository at this point in the history
  • Loading branch information
pauldambra committed Nov 18, 2024
1 parent cb08a81 commit dcd4691
Show file tree
Hide file tree
Showing 19 changed files with 145 additions and 0 deletions.
43 changes: 43 additions & 0 deletions eslint-rules/no-direct-set-timeout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure setTimeout is only used when imported from utils/prototype-utils',
category: 'Best Practices',
recommended: false,
},
schema: [],
messages: {
restrictedImport: 'setTimeout must be imported from utils/prototype-utils.',
},
},
create(context) {
let importedFromTargetFile = false
const targetFileName = 'utils/prototype-utils' // Simplified target check

return {
ImportDeclaration(node) {
if (node.source.value.includes(targetFileName)) {
// Check if 'setTimeout' is specifically imported
const importedSpecifiers = node.specifiers.map(
(specifier) => specifier.imported && specifier.imported.name
)
if (importedSpecifiers.includes('setTimeout')) {
importedFromTargetFile = true
}
}
},
CallExpression(node) {
// Check if `setTimeout` is called
if (node.callee.type === 'Identifier' && node.callee.name === 'setTimeout') {
if (!importedFromTargetFile) {
context.report({
node,
messageId: 'restrictedImport',
})
}
}
},
}
},
}
1 change: 1 addition & 0 deletions src/__tests__/consent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { uuidv7 } from '../uuidv7'
import { isNull } from '../utils/type-utils'
import { document, assignableWindow, navigator } from '../utils/globals'
import { PostHogConfig } from '../types'
import { setTimeout } from '../utils/prototype-utils'

const DEFAULT_PERSISTENCE_PREFIX = `__ph_opt_in_out_`
const CUSTOM_PERSISTENCE_PREFIX = `𝓶𝓶𝓶𝓬𝓸𝓸𝓴𝓲𝓮𝓼`
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/posthog-core.loaded.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createPosthogInstance } from './helpers/posthog-instance'
import { uuidv7 } from '../uuidv7'
import { PostHog } from '../posthog-core'
import { setTimeout } from '../utils/prototype-utils'

jest.useFakeTimers()

Expand Down
1 change: 1 addition & 0 deletions src/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { extendURLParams, request } from '../request'
import { Compression, RequestOptions } from '../types'
import { setTimeout } from '../utils/prototype-utils'

jest.mock('../utils/globals', () => ({
...jest.requireActual('../utils/globals'),
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/segment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { PostHog } from '../posthog-core'
import { SegmentContext, SegmentPlugin } from '../extensions/segment-integration'
import { USER_STATE } from '../constants'
import { setTimeout } from '../utils/prototype-utils'

describe(`Segment integration`, () => {
let segment: any
Expand Down
1 change: 1 addition & 0 deletions src/decide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { STORED_GROUP_PROPERTIES_KEY, STORED_PERSON_PROPERTIES_KEY } from './con

import { logger } from './utils/logger'
import { document, assignableWindow } from './utils/globals'
import { setTimeout } from './utils/prototype-utils'

export class Decide {
constructor(private readonly instance: PostHog) {
Expand Down
1 change: 1 addition & 0 deletions src/entrypoints/recorder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { record as rrwebRecord } from '@rrweb/record'
import { getRecordConsolePlugin } from '@rrweb/rrweb-plugin-console-record'
import { setTimeout } from '../utils/prototype-utils'

// rrweb/network@1 code starts
// most of what is below here will be removed when rrweb release their code for this
Expand Down
1 change: 1 addition & 0 deletions src/extensions/replay/sessionrecording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
SESSION_RECORDING_SAMPLE_RATE,
SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION,
} from '../../constants'
import { setTimeout } from '../../utils/prototype-utils'
import {
estimateSize,
INCREMENTAL_SNAPSHOT_EVENT_TYPE,
Expand Down
1 change: 1 addition & 0 deletions src/extensions/surveys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from './surveys/components/QuestionTypes'
import { logger } from '../utils/logger'
import { Cancel } from './surveys/components/QuestionHeader'
import { setTimeout } from '../utils/prototype-utils'

// We cast the types here which is dangerous but protected by the top level generateSurveys call
const window = _window as Window & typeof globalThis
Expand Down
1 change: 1 addition & 0 deletions src/extensions/web-vitals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { logger } from '../../utils/logger'
import { isBoolean, isNullish, isNumber, isObject, isUndefined } from '../../utils/type-utils'
import { WEB_VITALS_ALLOWED_METRICS, WEB_VITALS_ENABLED_SERVER_SIDE } from '../../constants'
import { assignableWindow, window } from '../../utils/globals'
import { setTimeout } from '../../utils/prototype-utils'

type WebVitalsMetricCallback = (metric: any) => void

Expand Down
1 change: 1 addition & 0 deletions src/heatmaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { isEmptyObject, isObject, isUndefined } from './utils/type-utils'
import { logger } from './utils/logger'
import { isElementInToolbar, isElementNode, isTag } from './utils/element-utils'
import { DeadClicksAutocapture, isDeadClicksEnabledForHeatmaps } from './extensions/dead-clicks-autocapture'
import { setTimeout } from './utils/prototype-utils'

const DEFAULT_FLUSH_INTERVAL = 5000
const HEATMAPS = 'heatmaps'
Expand Down
1 change: 1 addition & 0 deletions src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { WebVitalsAutocapture } from './extensions/web-vitals'
import { WebExperiments } from './web-experiments'
import { PostHogExceptions } from './posthog-exceptions'
import { DeadClicksAutocapture, isDeadClicksEnabledForAutocapture } from './extensions/dead-clicks-autocapture'
import { setTimeout } from './utils/prototype-utils'

/*
SIMPLE STYLE GUIDE:
Expand Down
1 change: 1 addition & 0 deletions src/posthog-featureflags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Compression,
} from './types'
import { PostHogPersistence } from './posthog-persistence'
import { setTimeout } from './utils/prototype-utils'

import {
PERSISTENCE_EARLY_ACCESS_FEATURES,
Expand Down
1 change: 1 addition & 0 deletions src/request-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { QueuedRequestOptions } from './types'
import { each } from './utils'

import { isArray, isUndefined } from './utils/type-utils'
import { setTimeout } from './utils/prototype-utils'

export class RequestQueue {
// We start in a paused state and only start flushing when enabled by the parent
Expand Down
1 change: 1 addition & 0 deletions src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { formDataToQuery } from './utils/request-utils'
import { logger } from './utils/logger'
import { AbortController, fetch, navigator, XMLHttpRequest } from './utils/globals'
import { gzipSync, strToU8 } from 'fflate'
import { setTimeout } from './utils/prototype-utils'

// eslint-disable-next-line compat/compat
export const SUPPORTS_REQUEST = !!XMLHttpRequest || !!fetch
Expand Down
1 change: 1 addition & 0 deletions src/retry-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { logger } from './utils/logger'
import { window } from './utils/globals'
import { PostHog } from './posthog-core'
import { extendURLParams } from './request'
import { setTimeout } from './utils/prototype-utils'

const thirtyMinutes = 30 * 60 * 1000

Expand Down
1 change: 1 addition & 0 deletions src/scroll-manager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { window } from './utils/globals'
import { PostHog } from './posthog-core'
import { isArray } from './utils/type-utils'
import { setTimeout } from './utils/prototype-utils'

export interface ScrollContext {
// scroll is how far down the page the user has scrolled,
Expand Down
81 changes: 81 additions & 0 deletions src/utils/prototype-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* adapted from https://github.com/getsentry/sentry-javascript/blob/72751dacb88c5b970d8bac15052ee8e09b28fd5d/packages/browser-utils/src/getNativeImplementation.ts#L27
* and https://github.com/PostHog/rrweb/blob/804380afbb1b9bed70b8792cb5a25d827f5c0cb5/packages/utils/src/index.ts#L31
* after a number of performance reports from Angular users
*/

import { assignableWindow } from './globals'
import { isFunction, isNativeFunction } from './type-utils'
import { logger } from './logger'

interface NativeImplementationsCache {
setTimeout: typeof assignableWindow.setTimeout
}

const cachedImplementations: Partial<NativeImplementationsCache> = {}

/**
* Get the native implementation of a browser function.
*
* This can be used to ensure we get an unwrapped version of a function, in cases where a wrapped function can lead to problems.
*
* The following methods can be retrieved:
* - `setTimeout`: This can be wrapped by e.g. Angular, causing change detection to be triggered.
* - `mutationObserverCtor`: This can be wrapped by e.g. Angular, causing change detection to be triggered.
*/
export function getNativeImplementation<T extends keyof NativeImplementationsCache>(
name: T
): NativeImplementationsCache[T] {
const cached = cachedImplementations[name]
if (cached) {
return cached
}

let impl = assignableWindow[name] as NativeImplementationsCache[T]

// Fast path to avoid DOM I/O
if (isNativeFunction(impl)) {
return (cachedImplementations[name] = impl.bind(assignableWindow) as NativeImplementationsCache[T])
}

const document = assignableWindow.document
if (document && isFunction(document.createElement)) {
try {
const sandbox = document.createElement('iframe')
sandbox.hidden = true
document.head.appendChild(sandbox)
const contentWindow = sandbox.contentWindow
if (contentWindow && contentWindow[name]) {
impl = contentWindow[name] as NativeImplementationsCache[T]
}
document.head.removeChild(sandbox)
} catch (e) {
// Could not create sandbox iframe, just use assignableWindow.xxx
logger.warn(`Could not create sandbox iframe for ${name} check, bailing to assignableWindow.${name}: `, e)
}
}

// Sanity check: This _should_ not happen, but if it does, we just skip caching...
// This can happen e.g. in tests where fetch may not be available in the env, or similar.
if (!impl) {
return impl
}

return (cachedImplementations[name] = impl.bind(assignableWindow) as NativeImplementationsCache[T])
}

/** Clear a cached implementation. */
export function clearCachedImplementation(name: keyof NativeImplementationsCache): void {
cachedImplementations[name] = undefined
}

/**
* Get an unwrapped `setTimeout` method.
* This ensures that even if e.g. Angular wraps `setTimeout`, we get the native implementation,
* avoiding triggering change detection.
*/
export function setTimeout(
...rest: Parameters<typeof assignableWindow.setTimeout>
): ReturnType<typeof assignableWindow.setTimeout> {
return getNativeImplementation('setTimeout')(...rest) as unknown as ReturnType<typeof assignableWindow.setTimeout>
}
5 changes: 5 additions & 0 deletions src/utils/type-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export const isFunction = function (f: any): f is (...args: any[]) => any {
// eslint-disable-next-line posthog-js/no-direct-function-check
return typeof f === 'function'
}

export const isNativeFunction = (func: unknown): boolean => {
return isFunction(func) && /^function\s+\w+\(\)\s+\{\s+\[native code\]\s+\}$/.test(func.toString())
}

// Underscore Addons
export const isObject = function (x: unknown): x is Record<string, any> {
// eslint-disable-next-line posthog-js/no-direct-object-check
Expand Down

0 comments on commit dcd4691

Please sign in to comment.