From 9041c5976548bc81cd0317361beba48c214eae8c Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Wed, 11 Dec 2024 12:09:40 -0600 Subject: [PATCH] wip --- .vscode/launch.json | 32 +- .../signals-integration-tests/package.json | 3 +- .../playwright.config.ts | 3 + .../src/tests/performance/index-page.ts | 7 + .../src/tests/performance/index.html | 14 + .../src/tests/performance/memory-leak.test.ts | 76 +++++ .../signals-vanilla/signals-ingestion.test.ts | 9 +- .../src/web/web-signals-types.ts | 6 +- packages/signals/signals/package.json | 2 +- .../src/core/buffer/__tests__/buffer.test.ts | 15 +- .../src/core/client/__tests__/redact.test.ts | 44 +++ .../signals/signals/src/core/client/redact.ts | 56 +++- .../signals/signals/src/core/emitter/index.ts | 13 +- .../__tests__/clean-text.test.ts} | 2 +- .../signal-generators/dom-gen/change-gen.ts | 212 ++++++++++++ .../{ => dom-gen}/dom-gen.ts | 132 +++++--- .../core/signal-generators/dom-gen/helpers.ts | 7 + .../core/signal-generators/dom-gen/index.ts | 20 ++ .../dom-gen/mutation-observer.ts | 309 ++++++++++++++++++ .../__tests__/network-generator.test.ts | 42 ++- .../network-gen/network-signals-filter.ts | 23 +- .../src/core/signal-generators/register.ts | 6 +- .../src/core/signal-generators/types.ts | 3 +- .../signals/src/core/signals/settings.ts | 16 +- .../signals/src/core/signals/signals.ts | 8 +- .../lib/debounce/__tests__/debounce.test.ts | 48 +++ .../signals/signals/src/lib/debounce/index.ts | 62 ++++ .../signals/src/lib/recent-queue/index.ts | 49 +++ .../signals/signals/src/types/settings.ts | 37 +++ 29 files changed, 1155 insertions(+), 101 deletions(-) create mode 100644 packages/signals/signals-integration-tests/src/tests/performance/index-page.ts create mode 100644 packages/signals/signals-integration-tests/src/tests/performance/index.html create mode 100644 packages/signals/signals-integration-tests/src/tests/performance/memory-leak.test.ts rename packages/signals/signals/src/core/signal-generators/{__tests__/dom-gen-helpers.test.ts => dom-gen/__tests__/clean-text.test.ts} (97%) create mode 100644 packages/signals/signals/src/core/signal-generators/dom-gen/change-gen.ts rename packages/signals/signals/src/core/signal-generators/{ => dom-gen}/dom-gen.ts (68%) create mode 100644 packages/signals/signals/src/core/signal-generators/dom-gen/helpers.ts create mode 100644 packages/signals/signals/src/core/signal-generators/dom-gen/index.ts create mode 100644 packages/signals/signals/src/core/signal-generators/dom-gen/mutation-observer.ts create mode 100644 packages/signals/signals/src/lib/debounce/__tests__/debounce.test.ts create mode 100644 packages/signals/signals/src/lib/debounce/index.ts create mode 100644 packages/signals/signals/src/lib/recent-queue/index.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 248b1f89d..c046791af 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,16 +6,10 @@ "request": "launch", "name": "Jest Current File", "program": "${workspaceFolder}/node_modules/.bin/jest", - "args": [ - "--testTimeout=100000", - "--findRelatedTests", - "${relativeFile}" - ], + "args": ["--testTimeout=100000", "--findRelatedTests", "${relativeFile}"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", - "skipFiles": [ - "/**" - ] + "skipFiles": ["/**"] }, { "type": "node", @@ -30,9 +24,17 @@ ], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", - "skipFiles": [ - "/**" - ] + "skipFiles": ["/**"] + }, + { + "name": "Run Jest Tests for Current Package", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["--testTimeout=100000"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "cwd": "${fileDirname}" }, { "type": "node", @@ -47,9 +49,7 @@ "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "program": "${workspaceFolder}/node_modules/jest/bin/jest", - "skipFiles": [ - "/**" - ] + "skipFiles": ["/**"] }, { "type": "node", @@ -64,9 +64,7 @@ "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "program": "${workspaceFolder}/node_modules/jest/bin/jest", - "skipFiles": [ - "/**" - ] + "skipFiles": ["/**"] }, { "name": "ts-node Current File", diff --git a/packages/signals/signals-integration-tests/package.json b/packages/signals/signals-integration-tests/package.json index 7cdef675d..22ddb5f87 100644 --- a/packages/signals/signals-integration-tests/package.json +++ b/packages/signals/signals-integration-tests/package.json @@ -8,7 +8,8 @@ "scripts": { ".": "yarn run -T turbo run --filter=@internal/signals-integration-tests...", "build": "webpack", - "test": "playwright test", + "test": "playwright test src/tests/signals-vanilla", + "test:perf": "playwright test src/tests/performance", "watch": "webpack -w", "lint": "yarn concurrently 'yarn:eslint .' 'yarn:tsc --noEmit'", "concurrently": "yarn run -T concurrently", diff --git a/packages/signals/signals-integration-tests/playwright.config.ts b/packages/signals/signals-integration-tests/playwright.config.ts index fe45d2455..9de10cd25 100644 --- a/packages/signals/signals-integration-tests/playwright.config.ts +++ b/packages/signals/signals-integration-tests/playwright.config.ts @@ -36,6 +36,9 @@ const config: PlaywrightTestConfig = { use: { /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on', + launchOptions: { + args: ['--enable-precise-memory-info'], + }, }, /* Configure projects for major browsers */ diff --git a/packages/signals/signals-integration-tests/src/tests/performance/index-page.ts b/packages/signals/signals-integration-tests/src/tests/performance/index-page.ts new file mode 100644 index 000000000..6b8df50ed --- /dev/null +++ b/packages/signals/signals-integration-tests/src/tests/performance/index-page.ts @@ -0,0 +1,7 @@ +import { BasePage } from '../../helpers/base-page-object' + +export class IndexPage extends BasePage { + constructor() { + super(`/performance/index.html`) + } +} diff --git a/packages/signals/signals-integration-tests/src/tests/performance/index.html b/packages/signals/signals-integration-tests/src/tests/performance/index.html new file mode 100644 index 000000000..7c113236e --- /dev/null +++ b/packages/signals/signals-integration-tests/src/tests/performance/index.html @@ -0,0 +1,14 @@ + + + + + + + + + + +
+ + + diff --git a/packages/signals/signals-integration-tests/src/tests/performance/memory-leak.test.ts b/packages/signals/signals-integration-tests/src/tests/performance/memory-leak.test.ts new file mode 100644 index 000000000..98696c6d7 --- /dev/null +++ b/packages/signals/signals-integration-tests/src/tests/performance/memory-leak.test.ts @@ -0,0 +1,76 @@ +import { test } from '@playwright/test' +import { IndexPage } from './index-page' +import { sleep } from '@segment/analytics-core' + +declare global { + interface Performance { + memory: { + usedJSHeapSize: number + totalJSHeapSize: number + } + } +} + +const basicEdgeFn = ` + // this is a process signal function + const processSignal = (signal) => { + if (signal.type === 'interaction') { + const eventName = signal.data.eventType + ' ' + '[' + signal.type + ']' + analytics.track(eventName, signal.data) + } + }` + +let indexPage: IndexPage + +test.beforeEach(async ({ page }) => { + indexPage = await new IndexPage().loadAndWait(page, basicEdgeFn) +}) + +test('memory leak', async ({ page }) => { + await indexPage.waitForSignalsApiFlush() + const ALLOWED_GROWTH = 1.1 + const getMemoryUsage = (): Promise => + page.evaluate(() => { + return performance.memory.usedJSHeapSize + }) + + const firstMemory = await getMemoryUsage() + + // add nodes + await page.evaluate(() => { + const target = document.getElementById('test-container')! + const NODE_COUNT = 2000 + for (let i = 0; i < NODE_COUNT; i++) { + const newNode = document.createElement('input') + newNode.type = 'text' + newNode.value = Math.random().toString() + target.appendChild(newNode) + } + }) + + await sleep(3000) + + // remove all the nodes + await page.evaluate(() => { + const target = document.getElementById('test-container')! + while (target.firstChild) { + target.removeChild(target.firstChild) + } + }) + + // Analyze memory usage + await sleep(3000) + const lastMemory = await getMemoryUsage() + + // Allow some fluctuation, but fail if there's a significant memory increase + if (lastMemory > firstMemory * ALLOWED_GROWTH) { + throw new Error( + `Memory leak detected! Initial: ${firstMemory}, Final: ${lastMemory}. Threshold` + ) + } else { + console.log( + 'Memory leak test passed!', + `initial: ${firstMemory}, final: ${lastMemory}, allowed growth: ${ALLOWED_GROWTH}` + ) + } +}) diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-ingestion.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-ingestion.test.ts index 962e2fc1a..e0f7262c0 100644 --- a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-ingestion.test.ts +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/signals-ingestion.test.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test' import { IndexPage } from './index-page' +import { waitForCondition } from '../../helpers/playwright-utils' const indexPage = new IndexPage() @@ -56,6 +57,10 @@ test('debug ingestion disabled and sample rate 1 -> will send the signal', async sampleRate: 1, } ) - await indexPage.fillNameInput('John Doe') - expect(indexPage.signalsAPI.getEvents('interaction')).toHaveLength(1) + await Promise.all([ + indexPage.fillNameInput('John Doe'), + waitForCondition( + () => indexPage.signalsAPI.getEvents('interaction').length > 0 + ), + ]) }) 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 6380011cb..4c3944310 100644 --- a/packages/signals/signals-runtime/src/web/web-signals-types.ts +++ b/packages/signals/signals-runtime/src/web/web-signals-types.ts @@ -25,9 +25,11 @@ type SubmitData = { target: SerializedTarget } -type ChangeData = { +export type ChangeData = { eventType: 'change' - [key: string]: unknown + target: SerializedTarget + listener: 'contenteditable' | 'onchange' | 'mutation' + change: JSONValue } export type InteractionSignal = RawSignal<'interaction', InteractionData> diff --git a/packages/signals/signals/package.json b/packages/signals/signals/package.json index 10a92cf72..380274a32 100644 --- a/packages/signals/signals/package.json +++ b/packages/signals/signals/package.json @@ -32,7 +32,7 @@ "build:bundle": "NODE_ENV=production yarn run webpack", "workerbox": "node scripts/build-workerbox.js", "assert-generated": "sh scripts/assert-workerbox-built.sh", - "watch": "yarn concurrently 'yarn build:bundle --watch' 'yarn build:esm --watch'", + "watch": "rm -rf dist && yarn concurrently 'yarn build:bundle --watch' 'yarn build:esm --watch'", "version": "sh scripts/version.sh", "watch:test": "yarn test --watch", "tsc": "yarn run -T tsc", diff --git a/packages/signals/signals/src/core/buffer/__tests__/buffer.test.ts b/packages/signals/signals/src/core/buffer/__tests__/buffer.test.ts index 8b639d79e..0a16f43f2 100644 --- a/packages/signals/signals/src/core/buffer/__tests__/buffer.test.ts +++ b/packages/signals/signals/src/core/buffer/__tests__/buffer.test.ts @@ -28,10 +28,11 @@ describe(getSignalBuffer, () => { }) it('should delete older signals when maxBufferSize is exceeded', async () => { - const signals = range(15).map((_, idx) => + const signals = range(15).map(() => createInteractionSignal({ - idx: idx, + change: 'change', eventType: 'change', + listener: 'listener', target: {}, }) ) @@ -46,9 +47,10 @@ describe(getSignalBuffer, () => { }) it('should delete older signals on initialize if current number exceeds maxBufferSize', async () => { - const signals = range(15).map((_, idx) => + const signals = range(15).map((_) => createInteractionSignal({ - idx: idx, + change: { foo: 'bar' }, + listener: 'onchange', eventType: 'change', target: {}, }) @@ -103,10 +105,11 @@ describe(getSignalBuffer, () => { }) it('should delete older signals when maxBufferSize is exceeded', async () => { - const signals = range(15).map((_, idx) => + const signals = range(15).map(() => createInteractionSignal({ - idx: idx, eventType: 'change', + change: { foo: 'bar' }, + listener: 'onchange', target: {}, }) ) diff --git a/packages/signals/signals/src/core/client/__tests__/redact.test.ts b/packages/signals/signals/src/core/client/__tests__/redact.test.ts index 7ffe5c626..10b1a065f 100644 --- a/packages/signals/signals/src/core/client/__tests__/redact.test.ts +++ b/packages/signals/signals/src/core/client/__tests__/redact.test.ts @@ -79,15 +79,59 @@ describe(redactSignalData, () => { it('should redact the value in the "target" property if the type is "interaction"', () => { const signal = factories.createInteractionSignal({ eventType: 'change', + change: { value: 'secret' }, + listener: 'onchange', target: { value: 'secret', formData: { password: '123' } }, }) const expected = factories.createInteractionSignal({ eventType: 'change', + change: { value: 'XXX' }, + listener: 'onchange', target: { value: 'XXX', formData: { password: 'XXX' } }, }) expect(redactSignalData(signal)).toEqual(expected) }) + it('should redact attributes in change and in target if the listener is "mutation"', () => { + const signal = factories.createInteractionSignal({ + eventType: 'change', + change: { 'aria-selected': 'value' }, + listener: 'mutation', + target: { + attributes: { 'aria-selected': 'value', foo: 'some other value' }, + textContent: 'secret', + innerText: 'secret', + }, + }) + const expected = factories.createInteractionSignal({ + eventType: 'change', + change: { 'aria-selected': 'XXX' }, + listener: 'mutation', + target: { + attributes: { 'aria-selected': 'XXX', foo: 'XXX' }, + textContent: 'XXX', + innerText: 'XXX', + }, + }) + expect(redactSignalData(signal)).toEqual(expected) + }) + + it('should redact the textContent and innerText in the "target" property if the listener is "contenteditable"', () => { + const signal = factories.createInteractionSignal({ + eventType: 'change', + listener: 'contenteditable', + change: { textContent: 'secret' }, + target: { textContent: 'secret', innerText: 'secret' }, + }) + const expected = factories.createInteractionSignal({ + eventType: 'change', + listener: 'contenteditable', + change: { textContent: 'XXX' }, + target: { textContent: 'XXX', innerText: 'XXX' }, + }) + expect(redactSignalData(signal)).toEqual(expected) + }) + it('should redact the values in the "data" property if the type is "network"', () => { const signal = factories.createNetworkSignal( { diff --git a/packages/signals/signals/src/core/client/redact.ts b/packages/signals/signals/src/core/client/redact.ts index 6f7a7eb61..b6bc60938 100644 --- a/packages/signals/signals/src/core/client/redact.ts +++ b/packages/signals/signals/src/core/client/redact.ts @@ -1,5 +1,10 @@ import { Signal } from '@segment/analytics-signals-runtime' +/** + * This is a very imperfect redaction. + * Issues: + * - innerText could contain sensitive data, and be leaked depending + */ export const redactSignalData = (signalArg: Signal): Signal => { const signal = structuredClone(signalArg) if (signal.type === 'interaction') { @@ -8,14 +13,63 @@ export const redactSignalData = (signalArg: Signal): Signal => { signal.data.target && typeof signal.data.target === 'object' ) { - if ('value' in signal.data.target) { + if ( + 'value' in signal.data.target && + signal.data.target.value !== undefined + ) { signal.data.target.value = redactJsonValues(signal.data.target.value) } + if ( + 'checked' in signal.data.target && + signal.data.target.checked !== undefined + ) { + signal.data.target.checked = redactJsonValues( + signal.data.target.checked + ) + } + if ('formData' in signal.data.target) { signal.data.target.formData = redactJsonValues( signal.data.target.formData ) } + + if (signal.data.eventType === 'change') { + if ('change' in signal.data) { + signal.data.change = redactJsonValues(signal.data.change) + } + + if (signal.data.listener === 'mutation') { + if ('innerText' in signal.data.target) { + signal.data.target.innerText = redactJsonValues( + signal.data.target.innerText + ) + } + if ('textContent' in signal.data.target) { + signal.data.target.textContent = redactJsonValues( + signal.data.target.textContent + ) + } + if ('attributes' in signal.data.target) { + signal.data.target.attributes = redactJsonValues( + signal.data.target.attributes + ) + } + } + + if (signal.data.listener === 'contenteditable') { + if ('textContent' in signal.data.target) { + signal.data.target.textContent = redactJsonValues( + signal.data.target.textContent + ) + } + if ('innerText' in signal.data.target) { + signal.data.target.innerText = redactJsonValues( + signal.data.target.innerText + ) + } + } + } } } else if (signal.type === 'network') { signal.data = redactJsonValues(signal.data, 2) diff --git a/packages/signals/signals/src/core/emitter/index.ts b/packages/signals/signals/src/core/emitter/index.ts index c9cc705e2..a480721c4 100644 --- a/packages/signals/signals/src/core/emitter/index.ts +++ b/packages/signals/signals/src/core/emitter/index.ts @@ -6,12 +6,23 @@ export interface EmitSignal { emit: (signal: Signal) => void } +const logSignal = (signal: Signal) => { + logger.info( + 'New signal:', + signal.type, + signal.data, + ...(signal.type === 'interaction' && 'change' in signal.data + ? ['change:', JSON.stringify(signal.data.change, null, 2)] + : []) + ) +} + export class SignalEmitter implements EmitSignal { private emitter = new Emitter<{ add: [Signal] }>() private listeners = new Set<(signal: Signal) => void>() emit(signal: Signal) { - logger.info('New signal:', signal.type, signal.data) + logSignal(signal) this.emitter.emit('add', signal) } diff --git a/packages/signals/signals/src/core/signal-generators/__tests__/dom-gen-helpers.test.ts b/packages/signals/signals/src/core/signal-generators/dom-gen/__tests__/clean-text.test.ts similarity index 97% rename from packages/signals/signals/src/core/signal-generators/__tests__/dom-gen-helpers.test.ts rename to packages/signals/signals/src/core/signal-generators/dom-gen/__tests__/clean-text.test.ts index a87f58a38..5e7f42a9c 100644 --- a/packages/signals/signals/src/core/signal-generators/__tests__/dom-gen-helpers.test.ts +++ b/packages/signals/signals/src/core/signal-generators/dom-gen/__tests__/clean-text.test.ts @@ -1,4 +1,4 @@ -import { cleanText } from '../dom-gen' +import { cleanText } from '../helpers' describe(cleanText, () => { test('should remove newline characters', () => { diff --git a/packages/signals/signals/src/core/signal-generators/dom-gen/change-gen.ts b/packages/signals/signals/src/core/signal-generators/dom-gen/change-gen.ts new file mode 100644 index 000000000..39c3666cd --- /dev/null +++ b/packages/signals/signals/src/core/signal-generators/dom-gen/change-gen.ts @@ -0,0 +1,212 @@ +import { logger } from '../../../lib/logger' +import { createInteractionSignal } from '../../../types/factories' +import { SignalEmitter } from '../../emitter' +import { SignalGlobalSettings } from '../../signals' +import { SignalGenerator } from '../types' +import { shouldIgnoreElement, parseElement } from './dom-gen' +import { + MutationObservable, + AttributeChangedEvent, + MutationObservableSettings, +} from './mutation-observer' + +export class MutationGeneratorSettings extends MutationObservableSettings {} + +export class MutationChangeGenerator implements SignalGenerator { + id = 'mutation' + private elMutObserver: MutationObservable + /** + * Custom selectors that should be ignored by the mutation observer + * e.g if you have a custom input field that is not a standard input field, you can add it here + */ + customSelectors = [] + constructor(settings: SignalGlobalSettings) { + this.elMutObserver = new MutationObservable(settings.mutationGenerator) + } + + register(emitter: SignalEmitter) { + type NormalizedAttributes = { [attributeName: string]: string | null } + const normalizeAttributes = ( + attributeMutation: AttributeChangedEvent + ): NormalizedAttributes => { + const attributes = + attributeMutation.attributes.reduce( + (acc, { attributeName, newValue }) => { + acc[attributeName] = newValue + return acc + }, + {} + ) + return attributes + } + + const callback = (ev: AttributeChangedEvent) => { + const target = ev.element as HTMLElement | null + if (!target || shouldIgnoreElement(target)) { + return + } + const el = parseElement(ev.element) + emitter.emit( + createInteractionSignal({ + eventType: 'change', + target: el, + listener: 'mutation', + change: normalizeAttributes(ev), + }) + ) + } + this.elMutObserver.subscribe(callback) + return () => this.elMutObserver.cleanup() + } +} + +export class OnChangeGenerator implements SignalGenerator { + id = 'change' + + register(emitter: SignalEmitter) { + /** + * Magic attributes that we use to normalize the API between the mutation listener + * and the onchange listener + */ + type ChangedEvent = { + checked?: boolean + value?: string + files?: string[] + selectedOptions?: { label: string; value: string }[] + } + + /** + * Extract the change from a change event for stateless elistener lements, + * so we can normalize the response between mutation listener changes and onchange listener events + */ + const parseChange = (target: HTMLElement): ChangedEvent | undefined => { + if (target instanceof HTMLSelectElement) { + return { + selectedOptions: Array.from(target.selectedOptions), + } + } + if (target instanceof HTMLTextAreaElement) { + return { value: target.value } + } + if (target instanceof HTMLInputElement) { + if ('value' in target || 'checked' in target) { + if (target.type === 'checkbox' || target.type === 'radio') { + return { checked: target.checked } + } + if (target.type === 'file') { + return { + files: Array.from(target.files ?? []).map((f) => f.name), + } + } + return { value: target.value } + } + } + } + const isHandledByMutationObserver = (el: HTMLElement): boolean => { + // check if the element is stateful -- if it is, we should ignore the onchange event since the mutation observer will pick it up + // input fields where can modify the field through interactions: + const inputTypesWithMutableValue = [ + 'text', + 'password', + 'email', + 'url', + 'tel', + 'number', + 'search', + 'date', + 'time', + 'datetime-local', + 'month', + 'week', + 'color', + 'range', + ] + const type = el.getAttribute('type') + const isInput = el instanceof HTMLInputElement + if ( + isInput && + (type === null || inputTypesWithMutableValue.includes(type)) + ) { + return el.getAttribute('value') !== null + } + return false + } + + // vanilla change events do not trigger dom updates. + const handleOnChangeEvent = (ev: Event) => { + const target = ev.target as HTMLElement | null + if (!target || shouldIgnoreElement(target)) { + return + } + // if the element is an input with a value, we can use mutation observer to get the new value, so we don't send duplicate signals + // this can really only happen with inputs, so we don't need to check for other elements + // This is very hacky -- onChange has different semantics than the value mutation (onchange event only fires when the element loses focus), so it's not a perfect match. + // We're not sure what the tolerance for duplicate-ish signals is since we have both strategies available? + if (isHandledByMutationObserver(target)) { + logger.debug('Ignoring onchange event in stateful element', target) + return + } + + const el = parseElement(target) + const change = parseChange(target) + if (!change) { + logger.debug( + 'No change found on element..., this should not happen', + el + ) + return + } + emitter.emit( + createInteractionSignal({ + eventType: 'change', + listener: 'onchange', + target: el, + change, + }) + ) + } + + document.addEventListener('change', handleOnChangeEvent, true) + return () => { + document.removeEventListener('change', handleOnChangeEvent, true) + } + } +} + +export class ContentEditableChangeGenerator implements SignalGenerator { + id = 'contenteditable' + register(emitter: SignalEmitter) { + const commitChange = (ev: Event) => { + if (!(ev.target instanceof HTMLElement)) { + return + } + const target = ev.target as HTMLElement + const el = parseElement(target) + emitter.emit( + createInteractionSignal({ + eventType: 'change', + listener: 'contenteditable', + target: el, + change: { + textContent: el.textContent || null, + }, + }) + ) + } + + const handleContentEditableChange = (ev: Event) => { + const target = ev.target as HTMLElement | null + const editable = target instanceof HTMLElement && target.isContentEditable + if (!editable) { + return + } + + // normalize so this behaves like a change event on an input field -- so it doesn't fire on every keystroke. + target.addEventListener('blur', commitChange, { once: true }) + } + document.addEventListener('input', handleContentEditableChange, true) + + return () => + document.removeEventListener('input', handleContentEditableChange) + } +} diff --git a/packages/signals/signals/src/core/signal-generators/dom-gen.ts b/packages/signals/signals/src/core/signal-generators/dom-gen/dom-gen.ts similarity index 68% rename from packages/signals/signals/src/core/signal-generators/dom-gen.ts rename to packages/signals/signals/src/core/signal-generators/dom-gen/dom-gen.ts index 94e39c4cb..18611ab6a 100644 --- a/packages/signals/signals/src/core/signal-generators/dom-gen.ts +++ b/packages/signals/signals/src/core/signal-generators/dom-gen/dom-gen.ts @@ -1,11 +1,11 @@ -import { URLChangeObservable } from '../../lib/detect-url-change' -import { logger } from '../../lib/logger' +import { URLChangeObservable } from '../../../lib/detect-url-change' import { createInteractionSignal, createNavigationSignal, -} from '../../types/factories' -import { SignalEmitter } from '../emitter' -import { SignalGenerator } from './types' +} from '../../../types/factories' +import { SignalEmitter } from '../../emitter' +import { SignalGenerator } from '../types' +import { cleanText } from './helpers' interface Label { textContent: string @@ -26,13 +26,16 @@ const parseLabels = ( labels: NodeListOf | null | undefined ): Label[] => { if (!labels) return [] - return [...labels] - .map((label) => ({ - id: label.id, - attributes: parseNodeMap(label.attributes), - textContent: label.textContent ? cleanText(label.textContent) : undefined, - })) - .filter((el): el is Label => Boolean(el.textContent)) + return [...labels].map(parseToLabel).filter((el): el is Label => Boolean(el)) +} + +const parseToLabel = (label: HTMLElement): Label => { + const textContent = label.textContent ? cleanText(label.textContent) : '' + return { + id: label.id, + attributes: parseNodeMap(label.attributes), + textContent, + } } const parseNodeMap = (nodeMap: NamedNodeMap): Record => { @@ -42,32 +45,46 @@ const parseNodeMap = (nodeMap: NamedNodeMap): Record => { }, {} as Record) } -export const cleanText = (str: string): string => { - return str - .replace(/[\r\n\t]+/g, ' ') // Replace newlines and tabs with a space - .replace(/\s\s+/g, ' ') // Replace multiple spaces with a single space - .replace(/\u00A0/g, ' ') // Replace non-breaking spaces with a regular space - .trim() // Trim leading and trailing spaces -} - interface ParsedElementBase { + /** + * The attributes of the element -- this is a key-value object of the attributes of the element + */ attributes: Record classList: string[] id: string + /** + * The labels associated with this element -- either from the `labels` property or from the `aria-labelledby` attribute + */ labels?: Label[] + /** + * The first label associated with this element -- either from the `labels` property or from the `aria-labelledby` attribute + */ label?: Label name?: string nodeName: string tagName: string title: string type?: string + /** + * The value of the element -- for inputs, this is the value of the input, for selects, this is the value of the selected option + */ value?: string + /** + * The value content of the element -- this is the value content of the element, stripped of newlines, tabs, and multiple spaces + */ textContent?: string + /** + * The inner value of the element -- this is the value content of the element, stripped of newlines, tabs, and multiple spaces + */ innerText?: string + /** + * The element referenced by the `aria-describedby` attribute + */ + describedBy?: Label } interface ParsedSelectElement extends ParsedElementBase { - selectedOptions: { value: string; text: string }[] + selectedOptions: { label: string; value: string }[] selectedIndex: number } interface ParsedInputElement extends ParsedElementBase { @@ -99,8 +116,26 @@ type AnyParsedElement = | ParsedMediaElement | ParsedElementBase -const parseElement = (el: HTMLElement): AnyParsedElement => { +/** + * Get the element referenced from an type + */ +const getReferencedElement = ( + el: HTMLElement, + attr: string +): HTMLElement | undefined => { + const value = el.getAttribute(attr) + if (!value) return undefined + return document.getElementById(value) ?? undefined +} + +export const parseElement = (el: HTMLElement): AnyParsedElement => { const labels = parseLabels((el as HTMLInputElement).labels) + const labeledBy = getReferencedElement(el, 'aria-labelledby') + const describedBy = getReferencedElement(el, 'aria-describedby') + if (labeledBy) { + const label = parseToLabel(labeledBy) + labels.unshift(label) + } const base: ParsedElementBase = { // adding a bunch of fields that are not on _all_ elements, but are on enough that it's useful to have them here. attributes: parseNodeMap(el.attributes), @@ -116,6 +151,7 @@ const parseElement = (el: HTMLElement): AnyParsedElement => { value: (el as HTMLInputElement).value, textContent: (el.textContent && cleanText(el.textContent)) ?? undefined, innerText: (el.innerText && cleanText(el.innerText)) ?? undefined, + describedBy: (describedBy && parseToLabel(describedBy)) ?? undefined, } if (el instanceof HTMLSelectElement) { @@ -123,7 +159,7 @@ const parseElement = (el: HTMLElement): AnyParsedElement => { ...base, selectedOptions: [...el.selectedOptions].map((option) => ({ value: option.value, - text: option.text, + label: option.label, })), selectedIndex: el.selectedIndex, } @@ -179,8 +215,22 @@ export class ClickSignalsGenerator implements SignalGenerator { } private getClosestClickableElement(el: HTMLElement): HTMLElement | null { - // if you click on a nested element, we want to get the closest clickable ancestor. Useful for things like buttons with nested text or images - return el.closest('button, a, [role="button"], [role="link"]') + // if you click on a nested element, we want to get the closest clickable ancestor. Useful for things like buttons with nested value or images + const selector = [ + 'button', + 'a', + 'option', + '[role="button"]', + '[role="link"]', + '[role="menuitem"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + '[role="tab"]', + '[role="option"]', + '[role="switch"]', + '[role="treeitem"]', + ].join(', ') + return el.closest(selector) } } @@ -208,28 +258,11 @@ export class FormSubmitGenerator implements SignalGenerator { } } -export class OnChangeGenerator implements SignalGenerator { - id = 'change' - register(emitter: SignalEmitter) { - const handleChange = (ev: Event) => { - const target = ev.target as HTMLElement | null - if (!target) return - if (target && target instanceof HTMLInputElement) { - if (target.type === 'password') { - logger.debug('Ignoring change event for input', target) - return - } - } - emitter.emit( - createInteractionSignal({ - eventType: 'change', - target: parseElement(target), - }) - ) - } - document.addEventListener('change', handleChange, true) - return () => document.removeEventListener('change', handleChange) +export const shouldIgnoreElement = (el: HTMLElement): boolean => { + if (el instanceof HTMLInputElement) { + return el.type === 'password' } + return false } export class OnNavigationEventGenerator implements SignalGenerator { @@ -272,10 +305,3 @@ export class OnNavigationEventGenerator implements SignalGenerator { } } } - -export const domGenerators = [ - ClickSignalsGenerator, - FormSubmitGenerator, - OnChangeGenerator, - OnNavigationEventGenerator, -] diff --git a/packages/signals/signals/src/core/signal-generators/dom-gen/helpers.ts b/packages/signals/signals/src/core/signal-generators/dom-gen/helpers.ts new file mode 100644 index 000000000..4383aa3cb --- /dev/null +++ b/packages/signals/signals/src/core/signal-generators/dom-gen/helpers.ts @@ -0,0 +1,7 @@ +export const cleanText = (str: string): string => { + return str + .replace(/[\r\n\t]+/g, ' ') // Replace newlines and tabs with a space + .replace(/\s\s+/g, ' ') // Replace multiple spaces with a single space + .replace(/\u00A0/g, ' ') // Replace non-breaking spaces with a regular space + .trim() // Trim leading and trailing spaces +} 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 new file mode 100644 index 000000000..1c2675154 --- /dev/null +++ b/packages/signals/signals/src/core/signal-generators/dom-gen/index.ts @@ -0,0 +1,20 @@ +import { + ClickSignalsGenerator, + FormSubmitGenerator, + OnNavigationEventGenerator, +} from './dom-gen' +import { + MutationChangeGenerator, + OnChangeGenerator, + ContentEditableChangeGenerator, +} from './change-gen' +import { SignalGeneratorClass } from '../types' + +export const domGenerators: SignalGeneratorClass[] = [ + MutationChangeGenerator, + OnChangeGenerator, + ContentEditableChangeGenerator, + ClickSignalsGenerator, + FormSubmitGenerator, + OnNavigationEventGenerator, +] diff --git a/packages/signals/signals/src/core/signal-generators/dom-gen/mutation-observer.ts b/packages/signals/signals/src/core/signal-generators/dom-gen/mutation-observer.ts new file mode 100644 index 000000000..fffa29376 --- /dev/null +++ b/packages/signals/signals/src/core/signal-generators/dom-gen/mutation-observer.ts @@ -0,0 +1,309 @@ +import { Emitter } from '@segment/analytics-generic-utils' +import { exists } from '../../../lib/exists' +import { debounceWithKey } from '../../../lib/debounce' + +const DEFAULT_OBSERVED_ATTRIBUTES = [ + 'aria-pressed', + 'aria-checked', + 'aria-modal', + 'aria-selected', + 'value', + 'checked', + 'data-selected', +] +const DEFAULT_OBSERVED_TAGS = ['input', 'label', 'option', 'select', 'textarea'] +const DEFAULT_OBSERVED_ROLES = [ + 'button', + 'checkbox', + 'dialog', + 'gridcell', + 'row', + 'searchbox', + 'menuitemcheckbox', + 'menuitemradio', + 'option', + 'radio', + 'scrollbar', + 'slider', + 'spinbutton', + 'switch', + 'tab', + 'treeitem', +] + +type AttributeMutation = { + attributeName: string + newValue: string | null +} +export type AttributeChangedEvent = { + element: HTMLElement + attributes: AttributeMutation[] +} + +export interface MutationObservableSettingsConfig { + extraSelectors?: string[] + pollIntervalMs?: number + debounceMs?: number + emitInputStrategy?: 'debounce-only' | 'blur' // the blur strategy seems to have an issue where it does not alwaus register when the page loads? It's also pretty finicky / manual. + observedRoles?: (defaultObservedRoles: string[]) => string[] + observedTags?: (defaultObservedTags: string[]) => string[] + observedAttributes?: (defaultObservedAttributes: string[]) => string[] +} + +export class MutationObservableSettings { + pollIntervalMs: number + debounceTextInputMs: number + emitInputStrategy: 'debounce-only' | 'blur' + extraSelectors: string[] + observedRoles: string[] + observedTags: string[] + observedAttributes: string[] + constructor(config: MutationObservableSettingsConfig = {}) { + const { + pollIntervalMs = 400, + debounceMs = 1000, + emitInputStrategy = 'debounce-only', + extraSelectors = [], + observedRoles, + observedTags, + observedAttributes, + } = config + if (pollIntervalMs < 300) { + throw new Error('Poll interval must be at least 300ms') + } + if (debounceMs < 100) { + throw new Error('Debounce must be at least 100ms') + } + this.emitInputStrategy = emitInputStrategy + this.pollIntervalMs = pollIntervalMs + this.debounceTextInputMs = debounceMs + this.extraSelectors = extraSelectors + + this.observedRoles = observedRoles + ? observedRoles(DEFAULT_OBSERVED_ROLES) + : DEFAULT_OBSERVED_ROLES + this.observedTags = observedTags + ? observedTags(DEFAULT_OBSERVED_TAGS) + : DEFAULT_OBSERVED_TAGS + this.observedAttributes = observedAttributes + ? observedAttributes(DEFAULT_OBSERVED_ATTRIBUTES) + : [] + } +} + +const shouldDebounce = (el: HTMLElement): boolean => { + const MUTABLE_INPUT_TYPES = new Set([ + 'text', + 'password', + 'email', + 'url', + 'tel', + 'number', + 'search', + 'date', + 'time', + 'datetime-local', + 'month', + 'week', + 'color', + 'range', + null, // same as 'text' + ]) + + const ROLES = new Set(['spinbutton']) + const isInput = + el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement + + const isContentEditable = el.isContentEditable + if (isContentEditable) { + return true + } + if (!isInput) { + return false + } + + const type = el.getAttribute('type') + if (MUTABLE_INPUT_TYPES.has(type)) { + return true + } + const role = el.getAttribute('role') + if (role && ROLES.has(role)) { + return true + } + return false +} + +/** + * This class is responsible for observing changes to elements in the DOM + * This is preferred over monitoring document 'change' events, as it captures changes to custom elements + */ +export class MutationObservable { + private settings: MutationObservableSettings + // Track observed elements to avoid duplicate observers + // WeakSet is used here to allow garbage collection of elements that are no longer in the DOM + private observedElements = new WeakSet() + private emitter = new ElementChangedEmitter() + private listeners = new Set<(event: AttributeChangedEvent) => void>() + + subscribe(fn: (event: AttributeChangedEvent) => void) { + this.listeners.add(fn) + this.emitter.on('attributeChanged', fn) + } + + cleanup() { + this.listeners.forEach((fn) => this.emitter.off('attributeChanged', fn)) + this.listeners.clear() + clearInterval(this.pollTimeout) + } + + private pollTimeout: ReturnType + + constructor( + settings: MutationObservableSettingsConfig | MutationObservableSettings = {} + ) { + this.settings = + settings instanceof MutationObservableSettings + ? settings + : new MutationObservableSettings(settings) + + this.checkForNewElements(this.emitter) + + this.pollTimeout = setInterval( + () => this.checkForNewElements(this.emitter), + this.settings.pollIntervalMs + ) + } + + private shouldEmitEvent(mut: AttributeMutation): boolean { + // Filter out aria-selected events where the new value is false, since there will always be another selected value -- otherwise, checked would/should be used + if (mut.attributeName === 'aria-selected' && mut.newValue === 'false') { + return false + } + return true + } + + private onChangeAdapter = new OnChangeEventAdapter() + + private observeElementAttributes( + element: HTMLElement, + attributes: string[], + emitter: ElementChangedEmitter + ) { + const _emitAttributeMutationEvent = (attributes: AttributeMutation[]) => { + emitter.emit('attributeChanged', { + element, + attributes, + }) + } + const addOnBlurListener = (attributeMutations: AttributeMutation[]) => + this.onChangeAdapter.onBlur(element, () => + _emitAttributeMutationEvent(attributeMutations) + ) + + const emit = + this.settings.emitInputStrategy === 'blur' + ? addOnBlurListener + : _emitAttributeMutationEvent + + const _emitAttributeMutationEventDebounced = shouldDebounce(element) + ? debounceWithKey( + emit, + // debounce based on the attribute names, so that we can debounce all changes to a single attribute. e.g if attribute "value" changes, that gets debounced, but if another attribute changes, that gets debounced separately + (m) => Object.keys(m.map((m) => m.attributeName)).sort(), + this.settings.debounceTextInputMs + ) + : _emitAttributeMutationEvent + + const cb: MutationCallback = (mutationsList) => { + const attributeMutations = mutationsList + .filter((m) => m.type === 'attributes') + .map((m) => { + const attributeName = m.attributeName + if (!attributeName) return + const newValue = element.getAttribute(attributeName) + + // if (newValue === null) { + // // Skip if attribute was removed on an input element (e.g a radio button that was unchecked) + // Cutting down on noise like this didn' work because, while it cut down on noise from RadioGroup, it also broke CustomCheckbox which used data-selected + // maybe there's a better way to do this with more specific logic + // return + // } + const v: AttributeMutation = { + attributeName, + newValue: newValue, + } + return v + }) + .filter(exists) + .filter((event) => this.shouldEmitEvent(event)) + + if (attributeMutations.length) { + _emitAttributeMutationEventDebounced(attributeMutations) + } + } + + const observer = new MutationObserver(cb) + + observer.observe(element, { + attributes: true, + attributeFilter: attributes, + subtree: false, + }) + + this.observedElements.add(element) + } + + private checkForNewElements(emitter: ElementChangedEmitter) { + const allElementSelectors = [ + ...this.settings.observedRoles.map((role) => `[role="${role}"]`), + ...this.settings.observedTags, + ...this.settings.extraSelectors, + ] + allElementSelectors.forEach((selector) => { + const elements = document.querySelectorAll(selector) + elements.forEach((element) => { + if (this.observedElements.has(element)) { + return + } + this.observeElementAttributes( + element as HTMLElement, + this.settings.observedAttributes, + emitter + ) + }) + }) + } +} + +/** + * This class is responsible for normalizing listener behavior so that events are only emitted once -- just like 'change' events + */ +class OnChangeEventAdapter { + private inputListeners: Map = new Map() + private removeListener(element: HTMLElement) { + const oldListener = this.inputListeners.get(element) + if (oldListener) { + element.removeEventListener('blur', oldListener) + } + } + onBlur(element: HTMLElement, cb: () => void) { + this.removeListener(element) + element.addEventListener('blur', cb, { once: true }) // once: true is important here, otherwise we'd get duplicate events if someone clicks out of the input and then back in + // on 'enter' keydown, we also want to emit the event + element.addEventListener( + 'keydown', + (event) => { + if (event.key === 'Enter') { + cb() + } + }, + { once: true } + ) + this.inputListeners.set(element, cb) + } +} + +type EmitterContract = { + attributeChanged: [AttributeChangedEvent] +} +class ElementChangedEmitter extends Emitter {} diff --git a/packages/signals/signals/src/core/signal-generators/network-gen/__tests__/network-generator.test.ts b/packages/signals/signals/src/core/signal-generators/network-gen/__tests__/network-generator.test.ts index be53ba44c..4e418cbff 100644 --- a/packages/signals/signals/src/core/signal-generators/network-gen/__tests__/network-generator.test.ts +++ b/packages/signals/signals/src/core/signal-generators/network-gen/__tests__/network-generator.test.ts @@ -283,7 +283,7 @@ describe(NetworkGenerator, () => { unregister() }) - it('emits signals for same domain if networkSignalsAllowSameDomain = false', async () => { + it('will not emit signals for same domain if networkSignalsAllowSameDomain = false', async () => { const mockEmitter = { emit: jest.fn() } const networkGenerator = new TestNetworkGenerator({ networkSignalsAllowList: ['foo.com'], @@ -301,7 +301,10 @@ describe(NetworkGenerator, () => { }) await sleep(100) - expect(mockEmitter.emit.mock.calls.length).toBe(0) + let requests = mockEmitter.emit.mock.calls.filter( + (c) => c[0].data.action === 'request' + ) + expect(requests.length).toBe(0) await window.fetch(`http://foo.com/test`, { method: 'POST', @@ -310,7 +313,10 @@ describe(NetworkGenerator, () => { }) await sleep(100) - expect(mockEmitter.emit.mock.calls.length).toBe(2) + requests = mockEmitter.emit.mock.calls.filter( + (c) => c[0].data.action === 'request' + ) + expect(requests.length).toBe(1) unregister() }) @@ -336,6 +342,36 @@ describe(NetworkGenerator, () => { unregister() }) + it('allows an explicit disallow list to override same-domain signals', async () => { + const mockEmitter = { emit: jest.fn() } + const networkGenerator = new TestNetworkGenerator({ + networkSignalsDisallowList: ['/foo'], + }) + const unregister = networkGenerator.register( + mockEmitter as unknown as SignalEmitter + ) + + await window.fetch(`/test/foo`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ key: 'value' }), + }) + + await window.fetch(`/test/bar`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ key: 'value' }), + }) + + await sleep(100) + const requests = mockEmitter.emit.mock.calls + .filter((c) => c[0].data.action === 'request') + .flatMap((c) => c[0].data.url) + expect(requests.length).toBe(1) + expect(requests[0]).toBe('http://localhost/test/bar') + unregister() + }) + it('always disallows segment api network signals', async () => { const mockEmitter = { emit: jest.fn() } const networkGenerator = new TestNetworkGenerator({ diff --git a/packages/signals/signals/src/core/signal-generators/network-gen/network-signals-filter.ts b/packages/signals/signals/src/core/signal-generators/network-gen/network-signals-filter.ts index c71ea59c6..c027626da 100644 --- a/packages/signals/signals/src/core/signal-generators/network-gen/network-signals-filter.ts +++ b/packages/signals/signals/src/core/signal-generators/network-gen/network-signals-filter.ts @@ -65,11 +65,12 @@ export class NetworkSignalsFilterList { ]) } + isDisallowed(url: string): boolean { + return this.disallowed.test(url) || this.disallowedDefaults.test(url) + } + isAllowed(url: string): boolean { - const disallowed = - this.disallowed.test(url) || this.disallowedDefaults.test(url) - const allowed = this.allowed.test(url) - return allowed && !disallowed + return this.allowed.test(url) } getRegexes() { @@ -93,10 +94,16 @@ export class NetworkSignalsFilter { const { networkSignalsFilterList, networkSignalsAllowSameDomain } = this.settings - const passesNetworkFilter = networkSignalsFilterList.isAllowed(url) - const allowedBecauseSameDomain = - networkSignalsAllowSameDomain && isSameDomain(url) - const allowed = passesNetworkFilter || allowedBecauseSameDomain + // anything that is disallowed takes precedence over the allow list. + if (networkSignalsFilterList.isDisallowed(url)) { + return false + } + + const allowed = + // allowed because it's in the allow list + networkSignalsFilterList.isAllowed(url) || + // allowed because it's the same domain + (networkSignalsAllowSameDomain && isSameDomain(url)) return allowed } } diff --git a/packages/signals/signals/src/core/signal-generators/register.ts b/packages/signals/signals/src/core/signal-generators/register.ts index 3aab35b6d..c2f6d08b1 100644 --- a/packages/signals/signals/src/core/signal-generators/register.ts +++ b/packages/signals/signals/src/core/signal-generators/register.ts @@ -1,17 +1,19 @@ import { logger } from '../../lib/logger' import { isClass } from '../../utils/is-class' import { SignalEmitter } from '../emitter' +import { SignalGlobalSettings } from '../signals' import { SignalGeneratorClass, SignalGenerator } from './types' export const registerGenerator = async ( emitter: SignalEmitter, - signalGenerators: (SignalGeneratorClass | SignalGenerator)[] + signalGenerators: (SignalGeneratorClass | SignalGenerator)[], + settings: SignalGlobalSettings ): Promise => { const _register = (gen: SignalGeneratorClass | SignalGenerator) => { logger.debug('Registering generator:', gen.id || (gen as any).name) if (isClass(gen)) { // Check if Gen is a function and has a constructor - return new gen().register(emitter) + return new gen(settings).register(emitter) } else { return gen.register(emitter) } diff --git a/packages/signals/signals/src/core/signal-generators/types.ts b/packages/signals/signals/src/core/signal-generators/types.ts index 65dcb43f2..16a594fbe 100644 --- a/packages/signals/signals/src/core/signal-generators/types.ts +++ b/packages/signals/signals/src/core/signal-generators/types.ts @@ -1,4 +1,5 @@ import type { SignalEmitter } from '../emitter' +import { SignalGlobalSettings } from '../signals' export interface SignalGenerator { /** @@ -15,5 +16,5 @@ export interface SignalGenerator { export interface SignalGeneratorClass { id?: string - new (): SignalGenerator + new (settings: SignalGlobalSettings): SignalGenerator } diff --git a/packages/signals/signals/src/core/signals/settings.ts b/packages/signals/signals/src/core/signals/settings.ts index a7469f6fb..256867620 100644 --- a/packages/signals/signals/src/core/signals/settings.ts +++ b/packages/signals/signals/src/core/signals/settings.ts @@ -6,6 +6,7 @@ import { SandboxSettingsConfig } from '../processor/sandbox' import { NetworkSettingsConfig } from '../signal-generators/network-gen' import { SignalsPluginSettingsConfig } from '../../types' import { WebStorage } from '../../lib/storage/web-storage' +import { MutationGeneratorSettings } from '../signal-generators/dom-gen/change-gen' export type SignalsSettingsConfig = Pick< SignalsPluginSettingsConfig, @@ -20,6 +21,11 @@ export type SignalsSettingsConfig = Pick< | 'networkSignalsDisallowList' | 'networkSignalsAllowSameDomain' | 'signalStorageType' + | 'mutationGenExtraSelectors' + | 'mutationGenObservedRoles' + | 'mutationGenObservedTags' + | 'mutationGenPollInterval' + | 'mutationGenObservedAttributes' > & { signalStorage?: SignalPersistentStorage processSignal?: string @@ -36,7 +42,7 @@ export class SignalGlobalSettings { ingestClient: SignalsIngestSettingsConfig network: NetworkSettingsConfig signalsDebug: SignalsDebugSettings - + mutationGenerator: MutationGeneratorSettings private sampleSuccess = false constructor(settings: SignalsSettingsConfig) { @@ -46,6 +52,14 @@ export class SignalGlobalSettings { ) } + this.mutationGenerator = new MutationGeneratorSettings({ + extraSelectors: settings.mutationGenExtraSelectors, + observedRoles: settings.mutationGenObservedRoles, + observedTags: settings.mutationGenObservedTags, + pollIntervalMs: settings.mutationGenPollInterval, + observedAttributes: settings.mutationGenObservedAttributes, + }) + this.signalsDebug = new SignalsDebugSettings( settings.disableSignalsRedaction, settings.enableSignalsIngestion diff --git a/packages/signals/signals/src/core/signals/signals.ts b/packages/signals/signals/src/core/signals/signals.ts index 7e76ca2e2..a70a3a313 100644 --- a/packages/signals/signals/src/core/signals/signals.ts +++ b/packages/signals/signals/src/core/signals/signals.ts @@ -147,6 +147,12 @@ export class Signals implements ISignals { async registerGenerator( generators: (SignalGeneratorClass | SignalGenerator)[] ): Promise { - this.cleanup.push(await registerGenerator(this.signalEmitter, generators)) + this.cleanup.push( + await registerGenerator( + this.signalEmitter, + generators, + this.globalSettings + ) + ) } } diff --git a/packages/signals/signals/src/lib/debounce/__tests__/debounce.test.ts b/packages/signals/signals/src/lib/debounce/__tests__/debounce.test.ts new file mode 100644 index 000000000..99fc75797 --- /dev/null +++ b/packages/signals/signals/src/lib/debounce/__tests__/debounce.test.ts @@ -0,0 +1,48 @@ +import { debounceWithKey } from '../index' + +jest.useFakeTimers() + +describe(debounceWithKey, () => { + type Callback = (...args: any[]) => void + let cb: jest.Mock + let debouncedCb: Callback + + beforeEach(() => { + cb = jest.fn() + const getKey = (obj: Record) => Object.keys(obj) + debouncedCb = debounceWithKey(cb, getKey, 300) + }) + + test('should call the function after the debounce time', () => { + debouncedCb({ foo: 1, bar: 2 }) + jest.advanceTimersByTime(200) + debouncedCb({ baz: 3 }) + jest.advanceTimersByTime(100) // in time for the first call, but not the second + expect(cb).toHaveBeenCalledTimes(1) + expect(cb).toHaveBeenCalledWith({ foo: 1, bar: 2 }) + debouncedCb({ hello: 1, world: 2 }) // just test that a new call does not reset the timer + jest.advanceTimersByTime(200) + expect(cb).toHaveBeenCalledTimes(2) + expect(cb).toHaveBeenCalledWith({ baz: 3 }) + jest.advanceTimersByTime(100) + expect(cb).toHaveBeenCalledTimes(3) + expect(cb).toHaveBeenCalledWith({ hello: 1, world: 2 }) + }) + + test('should debounce multiple calls with the same key group', () => { + debouncedCb({ foo: 1, bar: 2 }) + debouncedCb({ foo: 1, bar: 3 }) + jest.advanceTimersByTime(300) + expect(cb).toHaveBeenCalledTimes(1) + expect(cb).toHaveBeenCalledWith({ foo: 1, bar: 3 }) + }) + + test('should require the exact same keys for debounce', () => { + debouncedCb({ foo: 1, bar: 2 }) + debouncedCb({ bar: 6 }) + jest.advanceTimersByTime(300) + expect(cb).toHaveBeenCalledTimes(2) + expect(cb).toHaveBeenCalledWith({ foo: 1, bar: 2 }) + expect(cb).toHaveBeenCalledWith({ bar: 6 }) + }) +}) diff --git a/packages/signals/signals/src/lib/debounce/index.ts b/packages/signals/signals/src/lib/debounce/index.ts new file mode 100644 index 000000000..4fbb49734 --- /dev/null +++ b/packages/signals/signals/src/lib/debounce/index.ts @@ -0,0 +1,62 @@ +export function debounce void>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeoutId: ReturnType | null = null + + return function (...args: Parameters) { + if (timeoutId) { + clearTimeout(timeoutId) + } + timeoutId = setTimeout(() => func(...args), wait) + } +} + +export function createConditionalDebouncer( + func: (obj: T, ...args: any[]) => void, + wait: number, + shouldDebounce: (newObj: T, prevObj: T) => boolean = () => true +): (obj: T) => void { + let lastObject: T | undefined + + const debouncedFunc = debounce(func, wait) + + return function (obj: T, ...args: any[]) { + if (shouldDebounce(obj, lastObject || obj)) { + debouncedFunc(obj, ...args) + } else { + func(obj, ...args) + } + + lastObject = obj + } +} + +/** + * Debounce with key-based partitioning, so that the debouncing is done per key group. + * @param func The function to debounce + * @param getKey A function that returns a key for the arguments passed to `func` -- the return type must be serializable + */ +export function debounceWithKey( + func: (...args: Args) => void, + getKey: (...args: Args) => object | string, + wait: number +): (...args: Args) => void { + const timers = new Map>() + + return (...args: Args) => { + const key = getKey(...args) + const keyGroup = typeof key === 'object' ? JSON.stringify(key) : key + + if (timers.has(keyGroup)) { + clearTimeout(timers.get(keyGroup)) + } + + const timer = setTimeout(() => { + timers.delete(keyGroup) + func(...args) + }, wait) + + timers.set(keyGroup, timer) + } +} diff --git a/packages/signals/signals/src/lib/recent-queue/index.ts b/packages/signals/signals/src/lib/recent-queue/index.ts new file mode 100644 index 000000000..16cb25db0 --- /dev/null +++ b/packages/signals/signals/src/lib/recent-queue/index.ts @@ -0,0 +1,49 @@ +interface RecentQueueItem { + event: T + timestamp: number +} + +/** + * Class that contains all events emitted in the last 300ms + * This is used to prevent duplicate events from being emitted -- + * @example + * const queue = new RecentQueue({ isEqual: (a, b) => a.attributeName === b.attributeName }) + */ +export class RecentQueue { + private eventQueue: RecentQueueItem[] = [] + private readonly retentionTime: number + private isEqual: (a: T, b: T) => boolean + constructor({ + isEqual, + retentionTime = 300, + }: { + isEqual: (a: T, b: T) => boolean + retentionTime?: number + }) { + this.retentionTime = retentionTime + this.isEqual = isEqual + } + + addEvent(event: T): void { + const timestamp = Date.now() + this.eventQueue.push({ event, timestamp }) + this.cleanup() + } + + getEvents(): T[] { + this.cleanup() + return this.eventQueue.map((item) => item.event) + } + + has(event: T): boolean { + this.cleanup() + return this.eventQueue.some((item) => this.isEqual(item.event, event)) + } + + private cleanup() { + const now = Date.now() + this.eventQueue = this.eventQueue.filter( + (item) => now - item.timestamp <= this.retentionTime + ) + } +} diff --git a/packages/signals/signals/src/types/settings.ts b/packages/signals/signals/src/types/settings.ts index 2ecc95508..2e8a6e45c 100644 --- a/packages/signals/signals/src/types/settings.ts +++ b/packages/signals/signals/src/types/settings.ts @@ -81,6 +81,43 @@ export interface SignalsPluginSettingsConfig { * @default 'indexDB' */ signalStorageType?: 'session' | 'indexDB' | undefined + + /** + * Custom selectors that map to components that should be observed for attribute changes (if the default list is not sufficient) + * @example [`[id="bar"]`, `.foo`] + */ + mutationGenExtraSelectors?: string[] + /** + * @example + * // add a role to the roles + * (defaultRoles) => [...defaultRoles, 'grid'] + * // remove a role from the roles + * (defaultRoles) => defaultRoles.filter(role => role !== 'grid') + */ + mutationGenObservedRoles?: (defaultRoles: string[]) => string[] + /** + * @example + * // add a new role to the roles + * (defaultRoles) => [...defaultRoles, 'video'] + * // remove a role from the roles + * (defaultRoles) => defaultRoles.filter(tag => tag.toLowerCase() !== 'video') + */ + mutationGenObservedTags?: (defaultTags: string[]) => string[] + /** + * How often to poll the DOM for new elements to observe (ms) + * @default 400 + */ + mutationGenPollInterval?: number + /** + * + * Which attributes to observe for changes on the observed elements. This is used for MutationObserver. + * @example + * // add a new attribute to watch for changes + * (defaultAttributes) => [...defaultAttributes, 'aria-label'] + * // remove an attribute from the list of attributes to watch for changes + * (defaultRoles) => defaultRoles.filter(tag => tag.toLowerCase() !== 'aria-selected') + */ + mutationGenObservedAttributes?: (defaultAttribtues: string[]) => string[] } export type RegexLike = RegExp | string