react-auto-tracking 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/index.cjs +415 -0
- package/dist/index.d.cts +33 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +33 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +414 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hank Lin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# react-auto-tracking
|
|
2
|
+
|
|
3
|
+
Global user interaction tracking for React apps (16–19). Captures clicks, form events, and ambient events via DOM event delegation with automatic React fiber introspection.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Zero-config** — works with any React 16–19 app, no provider or HOC needed
|
|
8
|
+
- **Fiber introspection** — automatically resolves the nearest React component name and props
|
|
9
|
+
- **Smart filtering** — only tracks interactive elements (buttons, links, ARIA roles, elements with React handlers)
|
|
10
|
+
- **Three event categories** — Pointer (click), Form (input/change/focus/blur), Ambient (scroll/keydown/keyup)
|
|
11
|
+
- **Listener options** — debounce, throttle, once, CSS selector filtering
|
|
12
|
+
- **Tree-shakeable** — ESM + CJS, zero runtime dependencies
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install react-auto-tracking
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { init } from 'react-auto-tracking'
|
|
24
|
+
|
|
25
|
+
const tracker = init()
|
|
26
|
+
|
|
27
|
+
// Track all clicks on interactive elements
|
|
28
|
+
const unsubscribe = tracker.on('click', (event) => {
|
|
29
|
+
console.log(event.targetElement.tagName) // 'BUTTON'
|
|
30
|
+
console.log(event.fiber?.componentName) // 'SubmitButton'
|
|
31
|
+
console.log(event.fiber?.props) // { variant: 'primary', onClick: [Function] }
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Clean up
|
|
35
|
+
unsubscribe() // remove this listener
|
|
36
|
+
tracker.destroy() // remove all listeners and DOM bindings
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## API
|
|
40
|
+
|
|
41
|
+
### `init(config?): Tracker`
|
|
42
|
+
|
|
43
|
+
Creates a tracker instance.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const tracker = init({
|
|
47
|
+
enabled: true, // toggle tracking on/off
|
|
48
|
+
ignoreSelectors: ['.no-track', '[data-private]'],
|
|
49
|
+
debug: false, // log events to console.debug
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### `tracker.on(eventType, callback, options?): unsubscribe`
|
|
54
|
+
|
|
55
|
+
Register a listener for a DOM event type. Returns an unsubscribe function.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// Basic
|
|
59
|
+
tracker.on('click', (event) => { ... })
|
|
60
|
+
|
|
61
|
+
// With options
|
|
62
|
+
tracker.on('scroll', handler, { throttle: 200 })
|
|
63
|
+
tracker.on('input', handler, { debounce: 300 })
|
|
64
|
+
tracker.on('click', handler, { once: true })
|
|
65
|
+
tracker.on('click', handler, { selector: 'nav a' })
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Multiple listeners can be registered for the same event type — each fires independently, similar to `addEventListener`.
|
|
69
|
+
|
|
70
|
+
### `tracker.getLastEvent(): TrackEvent | null`
|
|
71
|
+
|
|
72
|
+
Returns the most recent tracked event, or `null` if none. Useful with `visibilitychange` or `beforeunload` to capture the last interaction before the user leaves:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
document.addEventListener('visibilitychange', () => {
|
|
76
|
+
if (document.visibilityState === 'hidden') {
|
|
77
|
+
const last = tracker.getLastEvent()
|
|
78
|
+
if (last) {
|
|
79
|
+
navigator.sendBeacon('/analytics', JSON.stringify({
|
|
80
|
+
component: last.fiber?.componentName,
|
|
81
|
+
element: last.targetElement.tagName,
|
|
82
|
+
}))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### `tracker.destroy()`
|
|
89
|
+
|
|
90
|
+
Removes all listeners and DOM event bindings. The tracker instance is inert after this call.
|
|
91
|
+
|
|
92
|
+
## TrackEvent
|
|
93
|
+
|
|
94
|
+
Each callback receives a `TrackEvent`:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
interface TrackEvent {
|
|
98
|
+
nativeEvent: Event // original DOM event
|
|
99
|
+
targetElement: Element // resolved trackable element (may differ from nativeEvent.target)
|
|
100
|
+
fiber: FiberInfo | null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface FiberInfo {
|
|
104
|
+
componentName: string | null // nearest React component
|
|
105
|
+
props: Readonly<Record<string, unknown>> // component props (original types, not stringified)
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The library intentionally keeps `TrackEvent` minimal — use `nativeEvent` and `targetElement` to extract any DOM information you need, and `fiber.props` to access rich, typed data from React components (avoiding the `[object Object]` problem of HTML `data-*` attributes).
|
|
110
|
+
|
|
111
|
+
## Event Categories
|
|
112
|
+
|
|
113
|
+
| Category | Events | Behavior |
|
|
114
|
+
|----------|--------|----------|
|
|
115
|
+
| **Pointer** | `click`, `touchstart`, `touchend` | Walks DOM ancestors to find interactive element (button, link, ARIA role, React handler) |
|
|
116
|
+
| **Form** | `input`, `change`, `focus`, `blur`, `submit` | Tracks target directly, skips disabled elements |
|
|
117
|
+
| **Ambient** | `scroll`, `keydown`, `keyup`, `copy`, `paste`, `resize`, `popstate`, `hashchange` | Tracks target directly, does not skip disabled |
|
|
118
|
+
|
|
119
|
+
## How It Works
|
|
120
|
+
|
|
121
|
+
1. **DOM delegation** — attaches a single capture-phase listener per event type on `document`
|
|
122
|
+
2. **Filtering** — determines if the target (or ancestor) is interactive based on HTML semantics, ARIA roles, or React fiber props
|
|
123
|
+
3. **Fiber resolution** — finds the nearest React component and extracts its name and props
|
|
124
|
+
4. **Dispatch** — invokes all registered callbacks with the `TrackEvent`
|
|
125
|
+
|
|
126
|
+
Capture phase (`addEventListener(..., true)`) ensures events are caught even if `stopPropagation()` is called downstream.
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
pnpm install
|
|
132
|
+
pnpm test # run tests
|
|
133
|
+
pnpm test:watch # watch mode
|
|
134
|
+
pnpm test:coverage # coverage report (80% threshold)
|
|
135
|
+
pnpm lint # oxlint
|
|
136
|
+
pnpm format # oxfmt (write)
|
|
137
|
+
pnpm format:check # oxfmt (check only)
|
|
138
|
+
pnpm typecheck # tsc --noEmit
|
|
139
|
+
pnpm build # ESM + CJS + .d.ts via tsdown
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
|
|
3
|
+
//#region src/filter/event-categories.ts
|
|
4
|
+
const EventCategory = {
|
|
5
|
+
Pointer: "pointer",
|
|
6
|
+
Form: "form",
|
|
7
|
+
Ambient: "ambient"
|
|
8
|
+
};
|
|
9
|
+
const EVENT_HANDLER_MAP = {
|
|
10
|
+
click: [
|
|
11
|
+
"onClick",
|
|
12
|
+
"onMouseDown",
|
|
13
|
+
"onMouseUp",
|
|
14
|
+
"onPointerDown",
|
|
15
|
+
"onPointerUp"
|
|
16
|
+
],
|
|
17
|
+
touchstart: ["onTouchStart"],
|
|
18
|
+
touchend: ["onTouchEnd"],
|
|
19
|
+
input: ["onChange", "onInput"],
|
|
20
|
+
change: ["onChange"],
|
|
21
|
+
focus: ["onFocus"],
|
|
22
|
+
blur: ["onBlur"],
|
|
23
|
+
submit: ["onSubmit"],
|
|
24
|
+
scroll: ["onScroll"],
|
|
25
|
+
keydown: ["onKeyDown"],
|
|
26
|
+
keyup: ["onKeyUp"],
|
|
27
|
+
copy: ["onCopy"],
|
|
28
|
+
paste: ["onPaste"]
|
|
29
|
+
};
|
|
30
|
+
const CATEGORY_MAP = {
|
|
31
|
+
click: EventCategory.Pointer,
|
|
32
|
+
touchstart: EventCategory.Pointer,
|
|
33
|
+
touchend: EventCategory.Pointer,
|
|
34
|
+
input: EventCategory.Form,
|
|
35
|
+
change: EventCategory.Form,
|
|
36
|
+
focus: EventCategory.Form,
|
|
37
|
+
blur: EventCategory.Form,
|
|
38
|
+
submit: EventCategory.Form,
|
|
39
|
+
scroll: EventCategory.Ambient,
|
|
40
|
+
keydown: EventCategory.Ambient,
|
|
41
|
+
keyup: EventCategory.Ambient,
|
|
42
|
+
copy: EventCategory.Ambient,
|
|
43
|
+
paste: EventCategory.Ambient,
|
|
44
|
+
resize: EventCategory.Ambient,
|
|
45
|
+
popstate: EventCategory.Ambient,
|
|
46
|
+
hashchange: EventCategory.Ambient
|
|
47
|
+
};
|
|
48
|
+
function getEventCategory(eventType) {
|
|
49
|
+
return CATEGORY_MAP[eventType] ?? EventCategory.Ambient;
|
|
50
|
+
}
|
|
51
|
+
function getHandlersForEvent(eventType) {
|
|
52
|
+
return EVENT_HANDLER_MAP[eventType] ?? [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/extract/fiber.ts
|
|
57
|
+
const FIBER_PREFIXES = ["__reactFiber$", "__reactInternalInstance$"];
|
|
58
|
+
const MAX_PARENT_DEPTH = 10;
|
|
59
|
+
let cachedKey = null;
|
|
60
|
+
function resolveFiber(element) {
|
|
61
|
+
let current = element;
|
|
62
|
+
let depth = 0;
|
|
63
|
+
while (current !== null && depth <= MAX_PARENT_DEPTH) {
|
|
64
|
+
const fiber = getFiberFromElement(current);
|
|
65
|
+
if (fiber !== null) return fiber;
|
|
66
|
+
current = current.parentElement;
|
|
67
|
+
depth++;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function getFiberFromElement(element) {
|
|
72
|
+
if (cachedKey !== null) {
|
|
73
|
+
const fiber = element[cachedKey];
|
|
74
|
+
if (fiber != null) return fiber;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
for (const key of Object.keys(element)) for (const prefix of FIBER_PREFIXES) if (key.startsWith(prefix)) {
|
|
78
|
+
cachedKey = key;
|
|
79
|
+
return element[key];
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const MAX_STACK_DEPTH = 50;
|
|
84
|
+
function extractFiberInfo(rawFiber) {
|
|
85
|
+
if (rawFiber === null) return null;
|
|
86
|
+
const fiber = rawFiber;
|
|
87
|
+
return {
|
|
88
|
+
componentName: findNearestComponentName(fiber),
|
|
89
|
+
props: fiber.memoizedProps ?? {}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function findNearestComponentName(fiber) {
|
|
93
|
+
let current = fiber.return;
|
|
94
|
+
let depth = 0;
|
|
95
|
+
while (current !== null && depth < MAX_STACK_DEPTH) {
|
|
96
|
+
const name = getComponentName(current);
|
|
97
|
+
if (name !== null) return name;
|
|
98
|
+
current = current.return;
|
|
99
|
+
depth++;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
function getComponentName(fiber) {
|
|
104
|
+
const type = fiber.type;
|
|
105
|
+
if (typeof type === "string") return null;
|
|
106
|
+
if (typeof type === "function") return type.displayName ?? type.name ?? null;
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/utils/safe-matches.ts
|
|
112
|
+
function safeMatches(element, selector) {
|
|
113
|
+
try {
|
|
114
|
+
return element.matches(selector);
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/filter/filter-engine.ts
|
|
122
|
+
const INTERACTIVE_TAGS = new Set([
|
|
123
|
+
"BUTTON",
|
|
124
|
+
"A",
|
|
125
|
+
"INPUT",
|
|
126
|
+
"SELECT",
|
|
127
|
+
"TEXTAREA",
|
|
128
|
+
"SUMMARY",
|
|
129
|
+
"DETAILS"
|
|
130
|
+
]);
|
|
131
|
+
const INTERACTIVE_ROLES = new Set([
|
|
132
|
+
"button",
|
|
133
|
+
"link",
|
|
134
|
+
"menuitem",
|
|
135
|
+
"tab",
|
|
136
|
+
"checkbox",
|
|
137
|
+
"radio",
|
|
138
|
+
"combobox",
|
|
139
|
+
"listbox",
|
|
140
|
+
"option",
|
|
141
|
+
"switch",
|
|
142
|
+
"slider",
|
|
143
|
+
"spinbutton",
|
|
144
|
+
"menuitemcheckbox",
|
|
145
|
+
"menuitemradio",
|
|
146
|
+
"treeitem",
|
|
147
|
+
"gridcell",
|
|
148
|
+
"textbox",
|
|
149
|
+
"searchbox"
|
|
150
|
+
]);
|
|
151
|
+
const MAX_ANCESTOR_DEPTH = 10;
|
|
152
|
+
function findTrackableElement(params) {
|
|
153
|
+
const { target, ignoreSelectors, eventType } = params;
|
|
154
|
+
switch (getEventCategory(eventType)) {
|
|
155
|
+
case EventCategory.Pointer: return findPointerTarget(target, ignoreSelectors, eventType);
|
|
156
|
+
case EventCategory.Form: return findFormTarget(target, ignoreSelectors);
|
|
157
|
+
case EventCategory.Ambient: return findAmbientTarget(target, ignoreSelectors);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function findPointerTarget(target, ignoreSelectors, eventType) {
|
|
161
|
+
let current = target;
|
|
162
|
+
let depth = 0;
|
|
163
|
+
while (current !== null && depth <= MAX_ANCESTOR_DEPTH) {
|
|
164
|
+
if (isIgnored(current, ignoreSelectors)) return null;
|
|
165
|
+
if (isDisabled(current)) return null;
|
|
166
|
+
const rawFiber = findInteractiveFiber(current, eventType);
|
|
167
|
+
if (rawFiber !== void 0) return {
|
|
168
|
+
element: current,
|
|
169
|
+
fiber: extractFiberInfo(rawFiber)
|
|
170
|
+
};
|
|
171
|
+
current = current.parentElement;
|
|
172
|
+
depth++;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
function findFormTarget(target, ignoreSelectors) {
|
|
177
|
+
if (isIgnored(target, ignoreSelectors)) return null;
|
|
178
|
+
if (isDisabled(target)) return null;
|
|
179
|
+
return {
|
|
180
|
+
element: target,
|
|
181
|
+
fiber: extractFiberInfo(resolveFiber(target))
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function findAmbientTarget(target, ignoreSelectors) {
|
|
185
|
+
if (isIgnored(target, ignoreSelectors)) return null;
|
|
186
|
+
return {
|
|
187
|
+
element: target,
|
|
188
|
+
fiber: extractFiberInfo(resolveFiber(target))
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Returns the raw fiber if the element is interactive, or undefined if not.
|
|
193
|
+
* null means "interactive but no fiber found" (semantic tag / ARIA role).
|
|
194
|
+
*/
|
|
195
|
+
function findInteractiveFiber(el, eventType) {
|
|
196
|
+
if (INTERACTIVE_TAGS.has(el.tagName)) return resolveFiber(el);
|
|
197
|
+
const role = el.getAttribute("role");
|
|
198
|
+
if (role !== null && INTERACTIVE_ROLES.has(role)) return resolveFiber(el);
|
|
199
|
+
const handlers = getHandlersForEvent(eventType);
|
|
200
|
+
if (handlers.length > 0) {
|
|
201
|
+
const fiber = resolveFiber(el);
|
|
202
|
+
if (fiber !== null) {
|
|
203
|
+
const props = fiber.memoizedProps;
|
|
204
|
+
if (props !== null && props !== void 0) {
|
|
205
|
+
for (const handler of handlers) if (typeof props[handler] === "function") return fiber;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function isIgnored(element, ignoreSelectors) {
|
|
211
|
+
return ignoreSelectors.some((selector) => safeMatches(element, selector));
|
|
212
|
+
}
|
|
213
|
+
function isDisabled(el) {
|
|
214
|
+
return el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/utils/debounce.ts
|
|
219
|
+
function debounce(fn, ms) {
|
|
220
|
+
let timeoutId = null;
|
|
221
|
+
const debounced = (...args) => {
|
|
222
|
+
if (timeoutId !== null) clearTimeout(timeoutId);
|
|
223
|
+
timeoutId = setTimeout(() => {
|
|
224
|
+
timeoutId = null;
|
|
225
|
+
fn(...args);
|
|
226
|
+
}, ms);
|
|
227
|
+
};
|
|
228
|
+
debounced.cancel = () => {
|
|
229
|
+
if (timeoutId !== null) {
|
|
230
|
+
clearTimeout(timeoutId);
|
|
231
|
+
timeoutId = null;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
return debounced;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region src/utils/throttle.ts
|
|
239
|
+
function throttle(fn, ms) {
|
|
240
|
+
let lastCallTime = 0;
|
|
241
|
+
let timeoutId = null;
|
|
242
|
+
let lastArgs = null;
|
|
243
|
+
const throttled = (...args) => {
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
const elapsed = now - lastCallTime;
|
|
246
|
+
if (elapsed >= ms) {
|
|
247
|
+
lastCallTime = now;
|
|
248
|
+
fn(...args);
|
|
249
|
+
} else {
|
|
250
|
+
lastArgs = args;
|
|
251
|
+
if (timeoutId === null) timeoutId = setTimeout(() => {
|
|
252
|
+
timeoutId = null;
|
|
253
|
+
lastCallTime = Date.now();
|
|
254
|
+
if (lastArgs !== null) {
|
|
255
|
+
fn(...lastArgs);
|
|
256
|
+
lastArgs = null;
|
|
257
|
+
}
|
|
258
|
+
}, ms - elapsed);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
throttled.cancel = () => {
|
|
262
|
+
if (timeoutId !== null) {
|
|
263
|
+
clearTimeout(timeoutId);
|
|
264
|
+
timeoutId = null;
|
|
265
|
+
}
|
|
266
|
+
lastArgs = null;
|
|
267
|
+
};
|
|
268
|
+
return throttled;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region src/core/registry.ts
|
|
273
|
+
function createRegistry() {
|
|
274
|
+
let entries = [];
|
|
275
|
+
function createEntry(eventType, callback, options) {
|
|
276
|
+
const wrappedCallback = wrapCallback(callback, options);
|
|
277
|
+
const entry = {
|
|
278
|
+
eventType,
|
|
279
|
+
wrappedCallback,
|
|
280
|
+
options,
|
|
281
|
+
unsubscribe: () => {
|
|
282
|
+
wrappedCallback.cancel?.();
|
|
283
|
+
entries = entries.filter((e) => e !== entry);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
return entry;
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
add(eventType, callback, options) {
|
|
290
|
+
const entry = createEntry(eventType, callback, options);
|
|
291
|
+
entries = [...entries, entry];
|
|
292
|
+
return entry.unsubscribe;
|
|
293
|
+
},
|
|
294
|
+
invoke(event) {
|
|
295
|
+
for (const entry of entries) {
|
|
296
|
+
if (entry.eventType !== event.nativeEvent.type) continue;
|
|
297
|
+
if (entry.options.selector != null) {
|
|
298
|
+
if (!safeMatches(event.targetElement, entry.options.selector)) continue;
|
|
299
|
+
}
|
|
300
|
+
entry.wrappedCallback(event);
|
|
301
|
+
if (entry.options.once === true) entry.unsubscribe();
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
getEventTypes() {
|
|
305
|
+
return new Set(entries.map((e) => e.eventType));
|
|
306
|
+
},
|
|
307
|
+
clear() {
|
|
308
|
+
for (const entry of entries) entry.wrappedCallback.cancel?.();
|
|
309
|
+
entries = [];
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function wrapCallback(callback, options) {
|
|
314
|
+
if (options.debounce != null) return debounce(callback, options.debounce);
|
|
315
|
+
if (options.throttle != null) return throttle(callback, options.throttle);
|
|
316
|
+
return callback;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/core/pipeline.ts
|
|
321
|
+
function createPipeline(config) {
|
|
322
|
+
const registry = createRegistry();
|
|
323
|
+
let lastEvent = null;
|
|
324
|
+
return {
|
|
325
|
+
handleEvent(domEvent) {
|
|
326
|
+
const target = domEvent.target;
|
|
327
|
+
if (!(target instanceof Element)) return;
|
|
328
|
+
const result = findTrackableElement({
|
|
329
|
+
target,
|
|
330
|
+
ignoreSelectors: config.ignoreSelectors,
|
|
331
|
+
eventType: domEvent.type
|
|
332
|
+
});
|
|
333
|
+
if (result === null) return;
|
|
334
|
+
const trackEvent = {
|
|
335
|
+
nativeEvent: domEvent,
|
|
336
|
+
targetElement: result.element,
|
|
337
|
+
fiber: result.fiber
|
|
338
|
+
};
|
|
339
|
+
registry.invoke(trackEvent);
|
|
340
|
+
lastEvent = trackEvent;
|
|
341
|
+
},
|
|
342
|
+
getLastEvent() {
|
|
343
|
+
return lastEvent;
|
|
344
|
+
},
|
|
345
|
+
addListener(eventType, callback, options) {
|
|
346
|
+
return registry.add(eventType, callback, options);
|
|
347
|
+
},
|
|
348
|
+
getEventTypes() {
|
|
349
|
+
return registry.getEventTypes();
|
|
350
|
+
},
|
|
351
|
+
clear() {
|
|
352
|
+
registry.clear();
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
//#endregion
|
|
358
|
+
//#region src/core/tracker.ts
|
|
359
|
+
function createTracker(config) {
|
|
360
|
+
const enabled = config?.enabled ?? true;
|
|
361
|
+
const ignoreSelectors = config?.ignoreSelectors ?? [];
|
|
362
|
+
const debug = config?.debug ?? false;
|
|
363
|
+
if (!enabled) return {
|
|
364
|
+
on: () => () => {},
|
|
365
|
+
getLastEvent: () => null,
|
|
366
|
+
destroy: () => {}
|
|
367
|
+
};
|
|
368
|
+
const pipeline = createPipeline({ ignoreSelectors });
|
|
369
|
+
const domListeners = /* @__PURE__ */ new Map();
|
|
370
|
+
let destroyed = false;
|
|
371
|
+
function ensureDomListener(eventType) {
|
|
372
|
+
if (domListeners.has(eventType)) return;
|
|
373
|
+
const handler = (event) => {
|
|
374
|
+
pipeline.handleEvent(event);
|
|
375
|
+
if (debug) {
|
|
376
|
+
const lastEvent = pipeline.getLastEvent();
|
|
377
|
+
if (lastEvent?.nativeEvent === event) console.debug("[react-auto-tracking]", lastEvent);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
document.addEventListener(eventType, handler, true);
|
|
381
|
+
domListeners.set(eventType, handler);
|
|
382
|
+
}
|
|
383
|
+
function removeDomListener(eventType) {
|
|
384
|
+
const handler = domListeners.get(eventType);
|
|
385
|
+
if (handler === void 0) return;
|
|
386
|
+
if (!pipeline.getEventTypes().has(eventType)) {
|
|
387
|
+
document.removeEventListener(eventType, handler, true);
|
|
388
|
+
domListeners.delete(eventType);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
on(eventType, callback, options) {
|
|
393
|
+
if (destroyed) return () => {};
|
|
394
|
+
ensureDomListener(eventType);
|
|
395
|
+
const unsub = pipeline.addListener(eventType, callback, options ?? {});
|
|
396
|
+
return () => {
|
|
397
|
+
unsub();
|
|
398
|
+
removeDomListener(eventType);
|
|
399
|
+
};
|
|
400
|
+
},
|
|
401
|
+
getLastEvent() {
|
|
402
|
+
return pipeline.getLastEvent();
|
|
403
|
+
},
|
|
404
|
+
destroy() {
|
|
405
|
+
if (destroyed) return;
|
|
406
|
+
destroyed = true;
|
|
407
|
+
pipeline.clear();
|
|
408
|
+
for (const [eventType, handler] of domListeners) document.removeEventListener(eventType, handler, true);
|
|
409
|
+
domListeners.clear();
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
//#endregion
|
|
415
|
+
exports.init = createTracker;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
interface TrackerConfig {
|
|
3
|
+
readonly enabled?: boolean;
|
|
4
|
+
readonly ignoreSelectors?: readonly string[];
|
|
5
|
+
readonly debug?: boolean;
|
|
6
|
+
}
|
|
7
|
+
interface Tracker {
|
|
8
|
+
on(eventType: string, callback: TrackCallback, options?: ListenerOptions): () => void;
|
|
9
|
+
getLastEvent(): TrackEvent | null;
|
|
10
|
+
destroy(): void;
|
|
11
|
+
}
|
|
12
|
+
interface ListenerOptions {
|
|
13
|
+
readonly debounce?: number;
|
|
14
|
+
readonly throttle?: number;
|
|
15
|
+
readonly once?: boolean;
|
|
16
|
+
readonly selector?: string;
|
|
17
|
+
}
|
|
18
|
+
interface TrackEvent {
|
|
19
|
+
readonly nativeEvent: Event;
|
|
20
|
+
readonly targetElement: Element;
|
|
21
|
+
readonly fiber: FiberInfo | null;
|
|
22
|
+
}
|
|
23
|
+
interface FiberInfo {
|
|
24
|
+
readonly componentName: string | null;
|
|
25
|
+
readonly props: Readonly<Record<string, unknown>>;
|
|
26
|
+
}
|
|
27
|
+
type TrackCallback = (event: TrackEvent) => void;
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/core/tracker.d.ts
|
|
30
|
+
declare function createTracker(config?: TrackerConfig): Tracker;
|
|
31
|
+
//#endregion
|
|
32
|
+
export { type FiberInfo, type ListenerOptions, type TrackCallback, type TrackEvent, type Tracker, type TrackerConfig, createTracker as init };
|
|
33
|
+
//# sourceMappingURL=index.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts","../src/core/tracker.ts"],"mappings":";UAEiB,aAAA;EAAA,SACN,OAAA;EAAA,SACA,eAAA;EAAA,SACA,KAAA;AAAA;AAAA,UAGM,OAAA;EACf,EAAA,CAAG,SAAA,UAAmB,QAAA,EAAU,aAAA,EAAe,OAAA,GAAU,eAAA;EACzD,YAAA,IAAgB,UAAA;EAChB,OAAA;AAAA;AAAA,UAGe,eAAA;EAAA,SACN,QAAA;EAAA,SACA,QAAA;EAAA,SACA,IAAA;EAAA,SACA,QAAA;AAAA;AAAA,UAGM,UAAA;EAAA,SACN,WAAA,EAAa,KAAA;EAAA,SACb,aAAA,EAAe,OAAA;EAAA,SACf,KAAA,EAAO,SAAA;AAAA;AAAA,UAGD,SAAA;EAAA,SACN,aAAA;EAAA,SACA,KAAA,EAAO,QAAA,CAAS,MAAA;AAAA;AAAA,KAGf,aAAA,IAAiB,KAAA,EAAO,UAAA;;;iBC7BpB,aAAA,CAAc,MAAA,GAAS,aAAA,GAAgB,OAAA"}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
interface TrackerConfig {
|
|
3
|
+
readonly enabled?: boolean;
|
|
4
|
+
readonly ignoreSelectors?: readonly string[];
|
|
5
|
+
readonly debug?: boolean;
|
|
6
|
+
}
|
|
7
|
+
interface Tracker {
|
|
8
|
+
on(eventType: string, callback: TrackCallback, options?: ListenerOptions): () => void;
|
|
9
|
+
getLastEvent(): TrackEvent | null;
|
|
10
|
+
destroy(): void;
|
|
11
|
+
}
|
|
12
|
+
interface ListenerOptions {
|
|
13
|
+
readonly debounce?: number;
|
|
14
|
+
readonly throttle?: number;
|
|
15
|
+
readonly once?: boolean;
|
|
16
|
+
readonly selector?: string;
|
|
17
|
+
}
|
|
18
|
+
interface TrackEvent {
|
|
19
|
+
readonly nativeEvent: Event;
|
|
20
|
+
readonly targetElement: Element;
|
|
21
|
+
readonly fiber: FiberInfo | null;
|
|
22
|
+
}
|
|
23
|
+
interface FiberInfo {
|
|
24
|
+
readonly componentName: string | null;
|
|
25
|
+
readonly props: Readonly<Record<string, unknown>>;
|
|
26
|
+
}
|
|
27
|
+
type TrackCallback = (event: TrackEvent) => void;
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/core/tracker.d.ts
|
|
30
|
+
declare function createTracker(config?: TrackerConfig): Tracker;
|
|
31
|
+
//#endregion
|
|
32
|
+
export { type FiberInfo, type ListenerOptions, type TrackCallback, type TrackEvent, type Tracker, type TrackerConfig, createTracker as init };
|
|
33
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/core/tracker.ts"],"mappings":";UAEiB,aAAA;EAAA,SACN,OAAA;EAAA,SACA,eAAA;EAAA,SACA,KAAA;AAAA;AAAA,UAGM,OAAA;EACf,EAAA,CAAG,SAAA,UAAmB,QAAA,EAAU,aAAA,EAAe,OAAA,GAAU,eAAA;EACzD,YAAA,IAAgB,UAAA;EAChB,OAAA;AAAA;AAAA,UAGe,eAAA;EAAA,SACN,QAAA;EAAA,SACA,QAAA;EAAA,SACA,IAAA;EAAA,SACA,QAAA;AAAA;AAAA,UAGM,UAAA;EAAA,SACN,WAAA,EAAa,KAAA;EAAA,SACb,aAAA,EAAe,OAAA;EAAA,SACf,KAAA,EAAO,SAAA;AAAA;AAAA,UAGD,SAAA;EAAA,SACN,aAAA;EAAA,SACA,KAAA,EAAO,QAAA,CAAS,MAAA;AAAA;AAAA,KAGf,aAAA,IAAiB,KAAA,EAAO,UAAA;;;iBC7BpB,aAAA,CAAc,MAAA,GAAS,aAAA,GAAgB,OAAA"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
//#region src/filter/event-categories.ts
|
|
2
|
+
const EventCategory = {
|
|
3
|
+
Pointer: "pointer",
|
|
4
|
+
Form: "form",
|
|
5
|
+
Ambient: "ambient"
|
|
6
|
+
};
|
|
7
|
+
const EVENT_HANDLER_MAP = {
|
|
8
|
+
click: [
|
|
9
|
+
"onClick",
|
|
10
|
+
"onMouseDown",
|
|
11
|
+
"onMouseUp",
|
|
12
|
+
"onPointerDown",
|
|
13
|
+
"onPointerUp"
|
|
14
|
+
],
|
|
15
|
+
touchstart: ["onTouchStart"],
|
|
16
|
+
touchend: ["onTouchEnd"],
|
|
17
|
+
input: ["onChange", "onInput"],
|
|
18
|
+
change: ["onChange"],
|
|
19
|
+
focus: ["onFocus"],
|
|
20
|
+
blur: ["onBlur"],
|
|
21
|
+
submit: ["onSubmit"],
|
|
22
|
+
scroll: ["onScroll"],
|
|
23
|
+
keydown: ["onKeyDown"],
|
|
24
|
+
keyup: ["onKeyUp"],
|
|
25
|
+
copy: ["onCopy"],
|
|
26
|
+
paste: ["onPaste"]
|
|
27
|
+
};
|
|
28
|
+
const CATEGORY_MAP = {
|
|
29
|
+
click: EventCategory.Pointer,
|
|
30
|
+
touchstart: EventCategory.Pointer,
|
|
31
|
+
touchend: EventCategory.Pointer,
|
|
32
|
+
input: EventCategory.Form,
|
|
33
|
+
change: EventCategory.Form,
|
|
34
|
+
focus: EventCategory.Form,
|
|
35
|
+
blur: EventCategory.Form,
|
|
36
|
+
submit: EventCategory.Form,
|
|
37
|
+
scroll: EventCategory.Ambient,
|
|
38
|
+
keydown: EventCategory.Ambient,
|
|
39
|
+
keyup: EventCategory.Ambient,
|
|
40
|
+
copy: EventCategory.Ambient,
|
|
41
|
+
paste: EventCategory.Ambient,
|
|
42
|
+
resize: EventCategory.Ambient,
|
|
43
|
+
popstate: EventCategory.Ambient,
|
|
44
|
+
hashchange: EventCategory.Ambient
|
|
45
|
+
};
|
|
46
|
+
function getEventCategory(eventType) {
|
|
47
|
+
return CATEGORY_MAP[eventType] ?? EventCategory.Ambient;
|
|
48
|
+
}
|
|
49
|
+
function getHandlersForEvent(eventType) {
|
|
50
|
+
return EVENT_HANDLER_MAP[eventType] ?? [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/extract/fiber.ts
|
|
55
|
+
const FIBER_PREFIXES = ["__reactFiber$", "__reactInternalInstance$"];
|
|
56
|
+
const MAX_PARENT_DEPTH = 10;
|
|
57
|
+
let cachedKey = null;
|
|
58
|
+
function resolveFiber(element) {
|
|
59
|
+
let current = element;
|
|
60
|
+
let depth = 0;
|
|
61
|
+
while (current !== null && depth <= MAX_PARENT_DEPTH) {
|
|
62
|
+
const fiber = getFiberFromElement(current);
|
|
63
|
+
if (fiber !== null) return fiber;
|
|
64
|
+
current = current.parentElement;
|
|
65
|
+
depth++;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
function getFiberFromElement(element) {
|
|
70
|
+
if (cachedKey !== null) {
|
|
71
|
+
const fiber = element[cachedKey];
|
|
72
|
+
if (fiber != null) return fiber;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
for (const key of Object.keys(element)) for (const prefix of FIBER_PREFIXES) if (key.startsWith(prefix)) {
|
|
76
|
+
cachedKey = key;
|
|
77
|
+
return element[key];
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const MAX_STACK_DEPTH = 50;
|
|
82
|
+
function extractFiberInfo(rawFiber) {
|
|
83
|
+
if (rawFiber === null) return null;
|
|
84
|
+
const fiber = rawFiber;
|
|
85
|
+
return {
|
|
86
|
+
componentName: findNearestComponentName(fiber),
|
|
87
|
+
props: fiber.memoizedProps ?? {}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function findNearestComponentName(fiber) {
|
|
91
|
+
let current = fiber.return;
|
|
92
|
+
let depth = 0;
|
|
93
|
+
while (current !== null && depth < MAX_STACK_DEPTH) {
|
|
94
|
+
const name = getComponentName(current);
|
|
95
|
+
if (name !== null) return name;
|
|
96
|
+
current = current.return;
|
|
97
|
+
depth++;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function getComponentName(fiber) {
|
|
102
|
+
const type = fiber.type;
|
|
103
|
+
if (typeof type === "string") return null;
|
|
104
|
+
if (typeof type === "function") return type.displayName ?? type.name ?? null;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/utils/safe-matches.ts
|
|
110
|
+
function safeMatches(element, selector) {
|
|
111
|
+
try {
|
|
112
|
+
return element.matches(selector);
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region src/filter/filter-engine.ts
|
|
120
|
+
const INTERACTIVE_TAGS = new Set([
|
|
121
|
+
"BUTTON",
|
|
122
|
+
"A",
|
|
123
|
+
"INPUT",
|
|
124
|
+
"SELECT",
|
|
125
|
+
"TEXTAREA",
|
|
126
|
+
"SUMMARY",
|
|
127
|
+
"DETAILS"
|
|
128
|
+
]);
|
|
129
|
+
const INTERACTIVE_ROLES = new Set([
|
|
130
|
+
"button",
|
|
131
|
+
"link",
|
|
132
|
+
"menuitem",
|
|
133
|
+
"tab",
|
|
134
|
+
"checkbox",
|
|
135
|
+
"radio",
|
|
136
|
+
"combobox",
|
|
137
|
+
"listbox",
|
|
138
|
+
"option",
|
|
139
|
+
"switch",
|
|
140
|
+
"slider",
|
|
141
|
+
"spinbutton",
|
|
142
|
+
"menuitemcheckbox",
|
|
143
|
+
"menuitemradio",
|
|
144
|
+
"treeitem",
|
|
145
|
+
"gridcell",
|
|
146
|
+
"textbox",
|
|
147
|
+
"searchbox"
|
|
148
|
+
]);
|
|
149
|
+
const MAX_ANCESTOR_DEPTH = 10;
|
|
150
|
+
function findTrackableElement(params) {
|
|
151
|
+
const { target, ignoreSelectors, eventType } = params;
|
|
152
|
+
switch (getEventCategory(eventType)) {
|
|
153
|
+
case EventCategory.Pointer: return findPointerTarget(target, ignoreSelectors, eventType);
|
|
154
|
+
case EventCategory.Form: return findFormTarget(target, ignoreSelectors);
|
|
155
|
+
case EventCategory.Ambient: return findAmbientTarget(target, ignoreSelectors);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function findPointerTarget(target, ignoreSelectors, eventType) {
|
|
159
|
+
let current = target;
|
|
160
|
+
let depth = 0;
|
|
161
|
+
while (current !== null && depth <= MAX_ANCESTOR_DEPTH) {
|
|
162
|
+
if (isIgnored(current, ignoreSelectors)) return null;
|
|
163
|
+
if (isDisabled(current)) return null;
|
|
164
|
+
const rawFiber = findInteractiveFiber(current, eventType);
|
|
165
|
+
if (rawFiber !== void 0) return {
|
|
166
|
+
element: current,
|
|
167
|
+
fiber: extractFiberInfo(rawFiber)
|
|
168
|
+
};
|
|
169
|
+
current = current.parentElement;
|
|
170
|
+
depth++;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
function findFormTarget(target, ignoreSelectors) {
|
|
175
|
+
if (isIgnored(target, ignoreSelectors)) return null;
|
|
176
|
+
if (isDisabled(target)) return null;
|
|
177
|
+
return {
|
|
178
|
+
element: target,
|
|
179
|
+
fiber: extractFiberInfo(resolveFiber(target))
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function findAmbientTarget(target, ignoreSelectors) {
|
|
183
|
+
if (isIgnored(target, ignoreSelectors)) return null;
|
|
184
|
+
return {
|
|
185
|
+
element: target,
|
|
186
|
+
fiber: extractFiberInfo(resolveFiber(target))
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Returns the raw fiber if the element is interactive, or undefined if not.
|
|
191
|
+
* null means "interactive but no fiber found" (semantic tag / ARIA role).
|
|
192
|
+
*/
|
|
193
|
+
function findInteractiveFiber(el, eventType) {
|
|
194
|
+
if (INTERACTIVE_TAGS.has(el.tagName)) return resolveFiber(el);
|
|
195
|
+
const role = el.getAttribute("role");
|
|
196
|
+
if (role !== null && INTERACTIVE_ROLES.has(role)) return resolveFiber(el);
|
|
197
|
+
const handlers = getHandlersForEvent(eventType);
|
|
198
|
+
if (handlers.length > 0) {
|
|
199
|
+
const fiber = resolveFiber(el);
|
|
200
|
+
if (fiber !== null) {
|
|
201
|
+
const props = fiber.memoizedProps;
|
|
202
|
+
if (props !== null && props !== void 0) {
|
|
203
|
+
for (const handler of handlers) if (typeof props[handler] === "function") return fiber;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function isIgnored(element, ignoreSelectors) {
|
|
209
|
+
return ignoreSelectors.some((selector) => safeMatches(element, selector));
|
|
210
|
+
}
|
|
211
|
+
function isDisabled(el) {
|
|
212
|
+
return el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
//#endregion
|
|
216
|
+
//#region src/utils/debounce.ts
|
|
217
|
+
function debounce(fn, ms) {
|
|
218
|
+
let timeoutId = null;
|
|
219
|
+
const debounced = (...args) => {
|
|
220
|
+
if (timeoutId !== null) clearTimeout(timeoutId);
|
|
221
|
+
timeoutId = setTimeout(() => {
|
|
222
|
+
timeoutId = null;
|
|
223
|
+
fn(...args);
|
|
224
|
+
}, ms);
|
|
225
|
+
};
|
|
226
|
+
debounced.cancel = () => {
|
|
227
|
+
if (timeoutId !== null) {
|
|
228
|
+
clearTimeout(timeoutId);
|
|
229
|
+
timeoutId = null;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
return debounced;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/utils/throttle.ts
|
|
237
|
+
function throttle(fn, ms) {
|
|
238
|
+
let lastCallTime = 0;
|
|
239
|
+
let timeoutId = null;
|
|
240
|
+
let lastArgs = null;
|
|
241
|
+
const throttled = (...args) => {
|
|
242
|
+
const now = Date.now();
|
|
243
|
+
const elapsed = now - lastCallTime;
|
|
244
|
+
if (elapsed >= ms) {
|
|
245
|
+
lastCallTime = now;
|
|
246
|
+
fn(...args);
|
|
247
|
+
} else {
|
|
248
|
+
lastArgs = args;
|
|
249
|
+
if (timeoutId === null) timeoutId = setTimeout(() => {
|
|
250
|
+
timeoutId = null;
|
|
251
|
+
lastCallTime = Date.now();
|
|
252
|
+
if (lastArgs !== null) {
|
|
253
|
+
fn(...lastArgs);
|
|
254
|
+
lastArgs = null;
|
|
255
|
+
}
|
|
256
|
+
}, ms - elapsed);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
throttled.cancel = () => {
|
|
260
|
+
if (timeoutId !== null) {
|
|
261
|
+
clearTimeout(timeoutId);
|
|
262
|
+
timeoutId = null;
|
|
263
|
+
}
|
|
264
|
+
lastArgs = null;
|
|
265
|
+
};
|
|
266
|
+
return throttled;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region src/core/registry.ts
|
|
271
|
+
function createRegistry() {
|
|
272
|
+
let entries = [];
|
|
273
|
+
function createEntry(eventType, callback, options) {
|
|
274
|
+
const wrappedCallback = wrapCallback(callback, options);
|
|
275
|
+
const entry = {
|
|
276
|
+
eventType,
|
|
277
|
+
wrappedCallback,
|
|
278
|
+
options,
|
|
279
|
+
unsubscribe: () => {
|
|
280
|
+
wrappedCallback.cancel?.();
|
|
281
|
+
entries = entries.filter((e) => e !== entry);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
return entry;
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
add(eventType, callback, options) {
|
|
288
|
+
const entry = createEntry(eventType, callback, options);
|
|
289
|
+
entries = [...entries, entry];
|
|
290
|
+
return entry.unsubscribe;
|
|
291
|
+
},
|
|
292
|
+
invoke(event) {
|
|
293
|
+
for (const entry of entries) {
|
|
294
|
+
if (entry.eventType !== event.nativeEvent.type) continue;
|
|
295
|
+
if (entry.options.selector != null) {
|
|
296
|
+
if (!safeMatches(event.targetElement, entry.options.selector)) continue;
|
|
297
|
+
}
|
|
298
|
+
entry.wrappedCallback(event);
|
|
299
|
+
if (entry.options.once === true) entry.unsubscribe();
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
getEventTypes() {
|
|
303
|
+
return new Set(entries.map((e) => e.eventType));
|
|
304
|
+
},
|
|
305
|
+
clear() {
|
|
306
|
+
for (const entry of entries) entry.wrappedCallback.cancel?.();
|
|
307
|
+
entries = [];
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function wrapCallback(callback, options) {
|
|
312
|
+
if (options.debounce != null) return debounce(callback, options.debounce);
|
|
313
|
+
if (options.throttle != null) return throttle(callback, options.throttle);
|
|
314
|
+
return callback;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
//#endregion
|
|
318
|
+
//#region src/core/pipeline.ts
|
|
319
|
+
function createPipeline(config) {
|
|
320
|
+
const registry = createRegistry();
|
|
321
|
+
let lastEvent = null;
|
|
322
|
+
return {
|
|
323
|
+
handleEvent(domEvent) {
|
|
324
|
+
const target = domEvent.target;
|
|
325
|
+
if (!(target instanceof Element)) return;
|
|
326
|
+
const result = findTrackableElement({
|
|
327
|
+
target,
|
|
328
|
+
ignoreSelectors: config.ignoreSelectors,
|
|
329
|
+
eventType: domEvent.type
|
|
330
|
+
});
|
|
331
|
+
if (result === null) return;
|
|
332
|
+
const trackEvent = {
|
|
333
|
+
nativeEvent: domEvent,
|
|
334
|
+
targetElement: result.element,
|
|
335
|
+
fiber: result.fiber
|
|
336
|
+
};
|
|
337
|
+
registry.invoke(trackEvent);
|
|
338
|
+
lastEvent = trackEvent;
|
|
339
|
+
},
|
|
340
|
+
getLastEvent() {
|
|
341
|
+
return lastEvent;
|
|
342
|
+
},
|
|
343
|
+
addListener(eventType, callback, options) {
|
|
344
|
+
return registry.add(eventType, callback, options);
|
|
345
|
+
},
|
|
346
|
+
getEventTypes() {
|
|
347
|
+
return registry.getEventTypes();
|
|
348
|
+
},
|
|
349
|
+
clear() {
|
|
350
|
+
registry.clear();
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/core/tracker.ts
|
|
357
|
+
function createTracker(config) {
|
|
358
|
+
const enabled = config?.enabled ?? true;
|
|
359
|
+
const ignoreSelectors = config?.ignoreSelectors ?? [];
|
|
360
|
+
const debug = config?.debug ?? false;
|
|
361
|
+
if (!enabled) return {
|
|
362
|
+
on: () => () => {},
|
|
363
|
+
getLastEvent: () => null,
|
|
364
|
+
destroy: () => {}
|
|
365
|
+
};
|
|
366
|
+
const pipeline = createPipeline({ ignoreSelectors });
|
|
367
|
+
const domListeners = /* @__PURE__ */ new Map();
|
|
368
|
+
let destroyed = false;
|
|
369
|
+
function ensureDomListener(eventType) {
|
|
370
|
+
if (domListeners.has(eventType)) return;
|
|
371
|
+
const handler = (event) => {
|
|
372
|
+
pipeline.handleEvent(event);
|
|
373
|
+
if (debug) {
|
|
374
|
+
const lastEvent = pipeline.getLastEvent();
|
|
375
|
+
if (lastEvent?.nativeEvent === event) console.debug("[react-auto-tracking]", lastEvent);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
document.addEventListener(eventType, handler, true);
|
|
379
|
+
domListeners.set(eventType, handler);
|
|
380
|
+
}
|
|
381
|
+
function removeDomListener(eventType) {
|
|
382
|
+
const handler = domListeners.get(eventType);
|
|
383
|
+
if (handler === void 0) return;
|
|
384
|
+
if (!pipeline.getEventTypes().has(eventType)) {
|
|
385
|
+
document.removeEventListener(eventType, handler, true);
|
|
386
|
+
domListeners.delete(eventType);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
on(eventType, callback, options) {
|
|
391
|
+
if (destroyed) return () => {};
|
|
392
|
+
ensureDomListener(eventType);
|
|
393
|
+
const unsub = pipeline.addListener(eventType, callback, options ?? {});
|
|
394
|
+
return () => {
|
|
395
|
+
unsub();
|
|
396
|
+
removeDomListener(eventType);
|
|
397
|
+
};
|
|
398
|
+
},
|
|
399
|
+
getLastEvent() {
|
|
400
|
+
return pipeline.getLastEvent();
|
|
401
|
+
},
|
|
402
|
+
destroy() {
|
|
403
|
+
if (destroyed) return;
|
|
404
|
+
destroyed = true;
|
|
405
|
+
pipeline.clear();
|
|
406
|
+
for (const [eventType, handler] of domListeners) document.removeEventListener(eventType, handler, true);
|
|
407
|
+
domListeners.clear();
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
//#endregion
|
|
413
|
+
export { createTracker as init };
|
|
414
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/filter/event-categories.ts","../src/extract/fiber.ts","../src/utils/safe-matches.ts","../src/filter/filter-engine.ts","../src/utils/debounce.ts","../src/utils/throttle.ts","../src/core/registry.ts","../src/core/pipeline.ts","../src/core/tracker.ts"],"sourcesContent":["export const EventCategory = {\n Pointer: 'pointer',\n Form: 'form',\n Ambient: 'ambient',\n} as const\n\nexport type EventCategory = (typeof EventCategory)[keyof typeof EventCategory]\n\n// Maps DOM event types to React handler prop names used to detect interactivity.\n// For click, we include mouse/pointer handlers because an element with only\n// onMouseDown (no onClick) is still interactive from a tracking perspective.\nconst EVENT_HANDLER_MAP: Readonly<Record<string, readonly string[]>> = {\n // Pointer — includes related mouse/pointer handlers to catch all interactive patterns\n click: ['onClick', 'onMouseDown', 'onMouseUp', 'onPointerDown', 'onPointerUp'],\n touchstart: ['onTouchStart'],\n touchend: ['onTouchEnd'],\n\n // Form\n input: ['onChange', 'onInput'],\n change: ['onChange'],\n focus: ['onFocus'],\n blur: ['onBlur'],\n submit: ['onSubmit'],\n\n // Ambient\n scroll: ['onScroll'],\n keydown: ['onKeyDown'],\n keyup: ['onKeyUp'],\n copy: ['onCopy'],\n paste: ['onPaste'],\n}\n\nconst CATEGORY_MAP: Readonly<Record<string, EventCategory>> = {\n click: EventCategory.Pointer,\n touchstart: EventCategory.Pointer,\n touchend: EventCategory.Pointer,\n\n input: EventCategory.Form,\n change: EventCategory.Form,\n focus: EventCategory.Form,\n blur: EventCategory.Form,\n submit: EventCategory.Form,\n\n scroll: EventCategory.Ambient,\n keydown: EventCategory.Ambient,\n keyup: EventCategory.Ambient,\n copy: EventCategory.Ambient,\n paste: EventCategory.Ambient,\n resize: EventCategory.Ambient,\n popstate: EventCategory.Ambient,\n hashchange: EventCategory.Ambient,\n}\n\nexport function getEventCategory(eventType: string): EventCategory {\n return CATEGORY_MAP[eventType] ?? EventCategory.Ambient\n}\n\nexport function getHandlersForEvent(eventType: string): readonly string[] {\n return EVENT_HANDLER_MAP[eventType] ?? []\n}\n","import type { FiberInfo } from '../types'\n\n// === Resolver: DOM element → raw fiber node ===\n\nconst FIBER_PREFIXES = ['__reactFiber$', '__reactInternalInstance$'] as const\nconst MAX_PARENT_DEPTH = 10\n\nlet cachedKey: string | null = null\n\nexport function resolveFiber(element: Element): object | null {\n let current: Element | null = element\n let depth = 0\n\n while (current !== null && depth <= MAX_PARENT_DEPTH) {\n const fiber = getFiberFromElement(current)\n if (fiber !== null) {\n return fiber\n }\n current = current.parentElement\n depth++\n }\n\n return null\n}\n\nfunction getFiberFromElement(element: Element): object | null {\n if (cachedKey !== null) {\n const fiber = (element as any)[cachedKey]\n if (fiber != null) return fiber as object\n return null\n }\n\n for (const key of Object.keys(element)) {\n for (const prefix of FIBER_PREFIXES) {\n if (key.startsWith(prefix)) {\n cachedKey = key\n return (element as any)[key] as object\n }\n }\n }\n\n return null\n}\n\nexport function resetFiberKeyCache(): void {\n cachedKey = null\n}\n\n// === Extractor: raw fiber node → FiberInfo ===\n\nconst MAX_STACK_DEPTH = 50\n\ninterface FiberNode {\n type: unknown\n memoizedProps: Record<string, unknown> | null\n return: FiberNode | null\n}\n\nexport function extractFiberInfo(rawFiber: object | null): FiberInfo | null {\n if (rawFiber === null) return null\n\n const fiber = rawFiber as FiberNode\n return {\n componentName: findNearestComponentName(fiber),\n props: fiber.memoizedProps ?? {},\n }\n}\n\nfunction findNearestComponentName(fiber: FiberNode): string | null {\n let current: FiberNode | null = fiber.return\n let depth = 0\n\n while (current !== null && depth < MAX_STACK_DEPTH) {\n const name = getComponentName(current)\n if (name !== null) return name\n current = current.return\n depth++\n }\n\n return null\n}\n\nfunction getComponentName(fiber: FiberNode): string | null {\n const type = fiber.type\n if (typeof type === 'string') return null\n if (typeof type === 'function') {\n return (type as any).displayName ?? type.name ?? null\n }\n return null\n}\n","export function safeMatches(element: Element, selector: string): boolean {\n try {\n return element.matches(selector)\n } catch {\n return false\n }\n}\n","import type { FiberInfo } from '../types'\nimport { getEventCategory, getHandlersForEvent, EventCategory } from './event-categories'\nimport { resolveFiber, extractFiberInfo } from '../extract/fiber'\nimport { safeMatches } from '../utils/safe-matches'\n\nconst INTERACTIVE_TAGS = new Set([\n 'BUTTON',\n 'A',\n 'INPUT',\n 'SELECT',\n 'TEXTAREA',\n 'SUMMARY',\n 'DETAILS',\n])\n\nconst INTERACTIVE_ROLES = new Set([\n // Original widget roles\n 'button',\n 'link',\n 'menuitem',\n 'tab',\n 'checkbox',\n 'radio',\n 'combobox',\n 'listbox',\n 'option',\n 'switch',\n 'slider',\n 'spinbutton',\n // Composite widget variants\n 'menuitemcheckbox',\n 'menuitemradio',\n 'treeitem',\n 'gridcell',\n // Input widget roles\n 'textbox',\n 'searchbox',\n])\n\nconst MAX_ANCESTOR_DEPTH = 10\n\nexport interface FilterResult {\n readonly element: Element\n readonly fiber: FiberInfo | null\n}\n\ninterface FindTrackableParams {\n readonly target: Element\n readonly ignoreSelectors: readonly string[]\n readonly eventType: string\n}\n\nexport function findTrackableElement(params: FindTrackableParams): FilterResult | null {\n const { target, ignoreSelectors, eventType } = params\n const category = getEventCategory(eventType)\n\n switch (category) {\n case EventCategory.Pointer:\n return findPointerTarget(target, ignoreSelectors, eventType)\n case EventCategory.Form:\n return findFormTarget(target, ignoreSelectors)\n case EventCategory.Ambient:\n return findAmbientTarget(target, ignoreSelectors)\n }\n}\n\nfunction findPointerTarget(\n target: Element,\n ignoreSelectors: readonly string[],\n eventType: string,\n): FilterResult | null {\n let current: Element | null = target\n let depth = 0\n\n while (current !== null && depth <= MAX_ANCESTOR_DEPTH) {\n if (isIgnored(current, ignoreSelectors)) return null\n if (isDisabled(current)) return null\n\n const rawFiber = findInteractiveFiber(current, eventType)\n if (rawFiber !== undefined) {\n return { element: current, fiber: extractFiberInfo(rawFiber) }\n }\n\n current = current.parentElement\n depth++\n }\n\n return null\n}\n\nfunction findFormTarget(target: Element, ignoreSelectors: readonly string[]): FilterResult | null {\n if (isIgnored(target, ignoreSelectors)) return null\n if (isDisabled(target)) return null\n return { element: target, fiber: extractFiberInfo(resolveFiber(target)) }\n}\n\nfunction findAmbientTarget(\n target: Element,\n ignoreSelectors: readonly string[],\n): FilterResult | null {\n if (isIgnored(target, ignoreSelectors)) return null\n return { element: target, fiber: extractFiberInfo(resolveFiber(target)) }\n}\n\n/**\n * Returns the raw fiber if the element is interactive, or undefined if not.\n * null means \"interactive but no fiber found\" (semantic tag / ARIA role).\n */\nfunction findInteractiveFiber(el: Element, eventType: string): object | null | undefined {\n // 1. Semantic tag\n if (INTERACTIVE_TAGS.has(el.tagName)) return resolveFiber(el)\n\n // 2. ARIA role\n const role = el.getAttribute('role')\n if (role !== null && INTERACTIVE_ROLES.has(role)) return resolveFiber(el)\n\n // 3. React event handler via fiber\n const handlers = getHandlersForEvent(eventType)\n if (handlers.length > 0) {\n const fiber = resolveFiber(el)\n if (fiber !== null) {\n const props = (fiber as any).memoizedProps\n if (props !== null && props !== undefined) {\n for (const handler of handlers) {\n if (typeof props[handler] === 'function') return fiber\n }\n }\n }\n }\n\n return undefined\n}\n\nexport function isIgnored(element: Element, ignoreSelectors: readonly string[]): boolean {\n return ignoreSelectors.some((selector) => safeMatches(element, selector))\n}\n\nexport function isDisabled(el: Element): boolean {\n return el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true'\n}\n","// eslint-disable-next-line @typescript-eslint/no-explicit-any\ninterface DebouncedFn<T extends (...args: any[]) => void> {\n (...args: Parameters<T>): void\n cancel(): void\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): DebouncedFn<T> {\n let timeoutId: ReturnType<typeof setTimeout> | null = null\n\n const debounced = (...args: Parameters<T>): void => {\n if (timeoutId !== null) {\n clearTimeout(timeoutId)\n }\n timeoutId = setTimeout(() => {\n timeoutId = null\n fn(...args)\n }, ms)\n }\n\n debounced.cancel = (): void => {\n if (timeoutId !== null) {\n clearTimeout(timeoutId)\n timeoutId = null\n }\n }\n\n return debounced\n}\n","// eslint-disable-next-line @typescript-eslint/no-explicit-any\ninterface ThrottledFn<T extends (...args: any[]) => void> {\n (...args: Parameters<T>): void\n cancel(): void\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function throttle<T extends (...args: any[]) => void>(fn: T, ms: number): ThrottledFn<T> {\n let lastCallTime = 0\n let timeoutId: ReturnType<typeof setTimeout> | null = null\n let lastArgs: Parameters<T> | null = null\n\n const throttled = (...args: Parameters<T>): void => {\n const now = Date.now()\n const elapsed = now - lastCallTime\n\n if (elapsed >= ms) {\n lastCallTime = now\n fn(...args)\n } else {\n lastArgs = args\n if (timeoutId === null) {\n timeoutId = setTimeout(() => {\n timeoutId = null\n lastCallTime = Date.now()\n if (lastArgs !== null) {\n fn(...lastArgs)\n lastArgs = null\n }\n }, ms - elapsed)\n }\n }\n }\n\n throttled.cancel = (): void => {\n if (timeoutId !== null) {\n clearTimeout(timeoutId)\n timeoutId = null\n }\n lastArgs = null\n }\n\n return throttled\n}\n","import type { TrackEvent, TrackCallback, ListenerOptions } from '../types'\nimport { debounce } from '../utils/debounce'\nimport { throttle } from '../utils/throttle'\nimport { safeMatches } from '../utils/safe-matches'\n\ninterface RegistryEntry {\n readonly eventType: string\n readonly wrappedCallback: TrackCallback & { cancel?: () => void }\n readonly options: ListenerOptions\n readonly unsubscribe: () => void\n}\n\nexport interface Registry {\n add(eventType: string, callback: TrackCallback, options: ListenerOptions): () => void\n invoke(event: TrackEvent): void\n getEventTypes(): Set<string>\n clear(): void\n}\n\nexport function createRegistry(): Registry {\n let entries: RegistryEntry[] = []\n\n function createEntry(\n eventType: string,\n callback: TrackCallback,\n options: ListenerOptions,\n ): RegistryEntry {\n const wrappedCallback = wrapCallback(callback, options)\n const entry: RegistryEntry = {\n eventType,\n wrappedCallback,\n options,\n unsubscribe: () => {\n wrappedCallback.cancel?.()\n entries = entries.filter((e) => e !== entry)\n },\n }\n return entry\n }\n\n return {\n add(eventType: string, callback: TrackCallback, options: ListenerOptions): () => void {\n const entry = createEntry(eventType, callback, options)\n entries = [...entries, entry]\n return entry.unsubscribe\n },\n\n invoke(event: TrackEvent): void {\n for (const entry of entries) {\n if (entry.eventType !== event.nativeEvent.type) continue\n\n if (entry.options.selector != null) {\n if (!safeMatches(event.targetElement, entry.options.selector)) {\n continue\n }\n }\n\n entry.wrappedCallback(event)\n\n // once: auto-unsubscribe after first fire\n if (entry.options.once === true) {\n entry.unsubscribe()\n }\n }\n },\n\n getEventTypes(): Set<string> {\n return new Set(entries.map((e) => e.eventType))\n },\n\n clear(): void {\n for (const entry of entries) {\n entry.wrappedCallback.cancel?.()\n }\n entries = []\n },\n }\n}\n\nfunction wrapCallback(\n callback: TrackCallback,\n options: ListenerOptions,\n): TrackCallback & { cancel?: () => void } {\n if (options.debounce != null) {\n return debounce(callback, options.debounce)\n }\n if (options.throttle != null) {\n return throttle(callback, options.throttle)\n }\n return callback\n}\n","import type { TrackEvent, TrackCallback, ListenerOptions } from '../types'\nimport { findTrackableElement } from '../filter/filter-engine'\nimport { createRegistry } from './registry'\n\nexport interface Pipeline {\n handleEvent(domEvent: Event): void\n getLastEvent(): TrackEvent | null\n addListener(eventType: string, callback: TrackCallback, options: ListenerOptions): () => void\n getEventTypes(): Set<string>\n clear(): void\n}\n\nexport interface PipelineConfig {\n readonly ignoreSelectors: readonly string[]\n}\n\nexport function createPipeline(config: PipelineConfig): Pipeline {\n const registry = createRegistry()\n let lastEvent: TrackEvent | null = null\n\n return {\n handleEvent(domEvent: Event): void {\n const target = domEvent.target\n if (!(target instanceof Element)) return\n\n const result = findTrackableElement({\n target,\n ignoreSelectors: config.ignoreSelectors,\n eventType: domEvent.type,\n })\n if (result === null) return\n\n const trackEvent: TrackEvent = {\n nativeEvent: domEvent,\n targetElement: result.element,\n fiber: result.fiber,\n }\n\n registry.invoke(trackEvent)\n lastEvent = trackEvent\n },\n\n getLastEvent(): TrackEvent | null {\n return lastEvent\n },\n\n addListener(eventType: string, callback: TrackCallback, options: ListenerOptions): () => void {\n return registry.add(eventType, callback, options)\n },\n\n getEventTypes(): Set<string> {\n return registry.getEventTypes()\n },\n\n clear(): void {\n registry.clear()\n },\n }\n}\n","import type { Tracker, TrackerConfig, TrackCallback, ListenerOptions } from '../types'\nimport { createPipeline } from './pipeline'\n\nexport function createTracker(config?: TrackerConfig): Tracker {\n const enabled = config?.enabled ?? true\n const ignoreSelectors = config?.ignoreSelectors ?? []\n const debug = config?.debug ?? false\n\n if (!enabled) {\n return {\n on: () => () => {},\n getLastEvent: () => null,\n destroy: () => {},\n }\n }\n\n const pipeline = createPipeline({ ignoreSelectors })\n const domListeners = new Map<string, (event: Event) => void>()\n let destroyed = false\n\n // Lazily attaches one capture-phase listener per event type on document.\n // Multiple on() calls for the same type share a single DOM listener;\n // the pipeline fans out to all registered callbacks internally.\n function ensureDomListener(eventType: string): void {\n if (domListeners.has(eventType)) return\n\n const handler = (event: Event): void => {\n pipeline.handleEvent(event)\n\n if (debug) {\n const lastEvent = pipeline.getLastEvent()\n if (lastEvent?.nativeEvent === event) {\n console.debug('[react-auto-tracking]', lastEvent)\n }\n }\n }\n\n document.addEventListener(eventType, handler, true)\n domListeners.set(eventType, handler)\n }\n\n function removeDomListener(eventType: string): void {\n const handler = domListeners.get(eventType)\n if (handler === undefined) return\n\n // Only remove if no more listeners for this type\n if (!pipeline.getEventTypes().has(eventType)) {\n document.removeEventListener(eventType, handler, true)\n domListeners.delete(eventType)\n }\n }\n\n return {\n on(eventType: string, callback: TrackCallback, options?: ListenerOptions): () => void {\n if (destroyed) return () => {}\n\n ensureDomListener(eventType)\n const unsub = pipeline.addListener(eventType, callback, options ?? {})\n\n return () => {\n unsub()\n removeDomListener(eventType)\n }\n },\n\n getLastEvent() {\n return pipeline.getLastEvent()\n },\n\n destroy(): void {\n if (destroyed) return\n destroyed = true\n\n pipeline.clear()\n\n for (const [eventType, handler] of domListeners) {\n document.removeEventListener(eventType, handler, true)\n }\n domListeners.clear()\n },\n }\n}\n"],"mappings":";AAAA,MAAa,gBAAgB;CAC3B,SAAS;CACT,MAAM;CACN,SAAS;CACV;AAOD,MAAM,oBAAiE;CAErE,OAAO;EAAC;EAAW;EAAe;EAAa;EAAiB;EAAc;CAC9E,YAAY,CAAC,eAAe;CAC5B,UAAU,CAAC,aAAa;CAGxB,OAAO,CAAC,YAAY,UAAU;CAC9B,QAAQ,CAAC,WAAW;CACpB,OAAO,CAAC,UAAU;CAClB,MAAM,CAAC,SAAS;CAChB,QAAQ,CAAC,WAAW;CAGpB,QAAQ,CAAC,WAAW;CACpB,SAAS,CAAC,YAAY;CACtB,OAAO,CAAC,UAAU;CAClB,MAAM,CAAC,SAAS;CAChB,OAAO,CAAC,UAAU;CACnB;AAED,MAAM,eAAwD;CAC5D,OAAO,cAAc;CACrB,YAAY,cAAc;CAC1B,UAAU,cAAc;CAExB,OAAO,cAAc;CACrB,QAAQ,cAAc;CACtB,OAAO,cAAc;CACrB,MAAM,cAAc;CACpB,QAAQ,cAAc;CAEtB,QAAQ,cAAc;CACtB,SAAS,cAAc;CACvB,OAAO,cAAc;CACrB,MAAM,cAAc;CACpB,OAAO,cAAc;CACrB,QAAQ,cAAc;CACtB,UAAU,cAAc;CACxB,YAAY,cAAc;CAC3B;AAED,SAAgB,iBAAiB,WAAkC;AACjE,QAAO,aAAa,cAAc,cAAc;;AAGlD,SAAgB,oBAAoB,WAAsC;AACxE,QAAO,kBAAkB,cAAc,EAAE;;;;;ACtD3C,MAAM,iBAAiB,CAAC,iBAAiB,2BAA2B;AACpE,MAAM,mBAAmB;AAEzB,IAAI,YAA2B;AAE/B,SAAgB,aAAa,SAAiC;CAC5D,IAAI,UAA0B;CAC9B,IAAI,QAAQ;AAEZ,QAAO,YAAY,QAAQ,SAAS,kBAAkB;EACpD,MAAM,QAAQ,oBAAoB,QAAQ;AAC1C,MAAI,UAAU,KACZ,QAAO;AAET,YAAU,QAAQ;AAClB;;AAGF,QAAO;;AAGT,SAAS,oBAAoB,SAAiC;AAC5D,KAAI,cAAc,MAAM;EACtB,MAAM,QAAS,QAAgB;AAC/B,MAAI,SAAS,KAAM,QAAO;AAC1B,SAAO;;AAGT,MAAK,MAAM,OAAO,OAAO,KAAK,QAAQ,CACpC,MAAK,MAAM,UAAU,eACnB,KAAI,IAAI,WAAW,OAAO,EAAE;AAC1B,cAAY;AACZ,SAAQ,QAAgB;;AAK9B,QAAO;;AAST,MAAM,kBAAkB;AAQxB,SAAgB,iBAAiB,UAA2C;AAC1E,KAAI,aAAa,KAAM,QAAO;CAE9B,MAAM,QAAQ;AACd,QAAO;EACL,eAAe,yBAAyB,MAAM;EAC9C,OAAO,MAAM,iBAAiB,EAAE;EACjC;;AAGH,SAAS,yBAAyB,OAAiC;CACjE,IAAI,UAA4B,MAAM;CACtC,IAAI,QAAQ;AAEZ,QAAO,YAAY,QAAQ,QAAQ,iBAAiB;EAClD,MAAM,OAAO,iBAAiB,QAAQ;AACtC,MAAI,SAAS,KAAM,QAAO;AAC1B,YAAU,QAAQ;AAClB;;AAGF,QAAO;;AAGT,SAAS,iBAAiB,OAAiC;CACzD,MAAM,OAAO,MAAM;AACnB,KAAI,OAAO,SAAS,SAAU,QAAO;AACrC,KAAI,OAAO,SAAS,WAClB,QAAQ,KAAa,eAAe,KAAK,QAAQ;AAEnD,QAAO;;;;;ACxFT,SAAgB,YAAY,SAAkB,UAA2B;AACvE,KAAI;AACF,SAAO,QAAQ,QAAQ,SAAS;SAC1B;AACN,SAAO;;;;;;ACCX,MAAM,mBAAmB,IAAI,IAAI;CAC/B;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;AAEF,MAAM,oBAAoB,IAAI,IAAI;CAEhC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CAEA;CACA;CACD,CAAC;AAEF,MAAM,qBAAqB;AAa3B,SAAgB,qBAAqB,QAAkD;CACrF,MAAM,EAAE,QAAQ,iBAAiB,cAAc;AAG/C,SAFiB,iBAAiB,UAAU,EAE5C;EACE,KAAK,cAAc,QACjB,QAAO,kBAAkB,QAAQ,iBAAiB,UAAU;EAC9D,KAAK,cAAc,KACjB,QAAO,eAAe,QAAQ,gBAAgB;EAChD,KAAK,cAAc,QACjB,QAAO,kBAAkB,QAAQ,gBAAgB;;;AAIvD,SAAS,kBACP,QACA,iBACA,WACqB;CACrB,IAAI,UAA0B;CAC9B,IAAI,QAAQ;AAEZ,QAAO,YAAY,QAAQ,SAAS,oBAAoB;AACtD,MAAI,UAAU,SAAS,gBAAgB,CAAE,QAAO;AAChD,MAAI,WAAW,QAAQ,CAAE,QAAO;EAEhC,MAAM,WAAW,qBAAqB,SAAS,UAAU;AACzD,MAAI,aAAa,OACf,QAAO;GAAE,SAAS;GAAS,OAAO,iBAAiB,SAAS;GAAE;AAGhE,YAAU,QAAQ;AAClB;;AAGF,QAAO;;AAGT,SAAS,eAAe,QAAiB,iBAAyD;AAChG,KAAI,UAAU,QAAQ,gBAAgB,CAAE,QAAO;AAC/C,KAAI,WAAW,OAAO,CAAE,QAAO;AAC/B,QAAO;EAAE,SAAS;EAAQ,OAAO,iBAAiB,aAAa,OAAO,CAAC;EAAE;;AAG3E,SAAS,kBACP,QACA,iBACqB;AACrB,KAAI,UAAU,QAAQ,gBAAgB,CAAE,QAAO;AAC/C,QAAO;EAAE,SAAS;EAAQ,OAAO,iBAAiB,aAAa,OAAO,CAAC;EAAE;;;;;;AAO3E,SAAS,qBAAqB,IAAa,WAA8C;AAEvF,KAAI,iBAAiB,IAAI,GAAG,QAAQ,CAAE,QAAO,aAAa,GAAG;CAG7D,MAAM,OAAO,GAAG,aAAa,OAAO;AACpC,KAAI,SAAS,QAAQ,kBAAkB,IAAI,KAAK,CAAE,QAAO,aAAa,GAAG;CAGzE,MAAM,WAAW,oBAAoB,UAAU;AAC/C,KAAI,SAAS,SAAS,GAAG;EACvB,MAAM,QAAQ,aAAa,GAAG;AAC9B,MAAI,UAAU,MAAM;GAClB,MAAM,QAAS,MAAc;AAC7B,OAAI,UAAU,QAAQ,UAAU,QAC9B;SAAK,MAAM,WAAW,SACpB,KAAI,OAAO,MAAM,aAAa,WAAY,QAAO;;;;;AAS3D,SAAgB,UAAU,SAAkB,iBAA6C;AACvF,QAAO,gBAAgB,MAAM,aAAa,YAAY,SAAS,SAAS,CAAC;;AAG3E,SAAgB,WAAW,IAAsB;AAC/C,QAAO,GAAG,aAAa,WAAW,IAAI,GAAG,aAAa,gBAAgB,KAAK;;;;;ACnI7E,SAAgB,SAA6C,IAAO,IAA4B;CAC9F,IAAI,YAAkD;CAEtD,MAAM,aAAa,GAAG,SAA8B;AAClD,MAAI,cAAc,KAChB,cAAa,UAAU;AAEzB,cAAY,iBAAiB;AAC3B,eAAY;AACZ,MAAG,GAAG,KAAK;KACV,GAAG;;AAGR,WAAU,eAAqB;AAC7B,MAAI,cAAc,MAAM;AACtB,gBAAa,UAAU;AACvB,eAAY;;;AAIhB,QAAO;;;;;ACpBT,SAAgB,SAA6C,IAAO,IAA4B;CAC9F,IAAI,eAAe;CACnB,IAAI,YAAkD;CACtD,IAAI,WAAiC;CAErC,MAAM,aAAa,GAAG,SAA8B;EAClD,MAAM,MAAM,KAAK,KAAK;EACtB,MAAM,UAAU,MAAM;AAEtB,MAAI,WAAW,IAAI;AACjB,kBAAe;AACf,MAAG,GAAG,KAAK;SACN;AACL,cAAW;AACX,OAAI,cAAc,KAChB,aAAY,iBAAiB;AAC3B,gBAAY;AACZ,mBAAe,KAAK,KAAK;AACzB,QAAI,aAAa,MAAM;AACrB,QAAG,GAAG,SAAS;AACf,gBAAW;;MAEZ,KAAK,QAAQ;;;AAKtB,WAAU,eAAqB;AAC7B,MAAI,cAAc,MAAM;AACtB,gBAAa,UAAU;AACvB,eAAY;;AAEd,aAAW;;AAGb,QAAO;;;;;ACvBT,SAAgB,iBAA2B;CACzC,IAAI,UAA2B,EAAE;CAEjC,SAAS,YACP,WACA,UACA,SACe;EACf,MAAM,kBAAkB,aAAa,UAAU,QAAQ;EACvD,MAAM,QAAuB;GAC3B;GACA;GACA;GACA,mBAAmB;AACjB,oBAAgB,UAAU;AAC1B,cAAU,QAAQ,QAAQ,MAAM,MAAM,MAAM;;GAE/C;AACD,SAAO;;AAGT,QAAO;EACL,IAAI,WAAmB,UAAyB,SAAsC;GACpF,MAAM,QAAQ,YAAY,WAAW,UAAU,QAAQ;AACvD,aAAU,CAAC,GAAG,SAAS,MAAM;AAC7B,UAAO,MAAM;;EAGf,OAAO,OAAyB;AAC9B,QAAK,MAAM,SAAS,SAAS;AAC3B,QAAI,MAAM,cAAc,MAAM,YAAY,KAAM;AAEhD,QAAI,MAAM,QAAQ,YAAY,MAC5B;SAAI,CAAC,YAAY,MAAM,eAAe,MAAM,QAAQ,SAAS,CAC3D;;AAIJ,UAAM,gBAAgB,MAAM;AAG5B,QAAI,MAAM,QAAQ,SAAS,KACzB,OAAM,aAAa;;;EAKzB,gBAA6B;AAC3B,UAAO,IAAI,IAAI,QAAQ,KAAK,MAAM,EAAE,UAAU,CAAC;;EAGjD,QAAc;AACZ,QAAK,MAAM,SAAS,QAClB,OAAM,gBAAgB,UAAU;AAElC,aAAU,EAAE;;EAEf;;AAGH,SAAS,aACP,UACA,SACyC;AACzC,KAAI,QAAQ,YAAY,KACtB,QAAO,SAAS,UAAU,QAAQ,SAAS;AAE7C,KAAI,QAAQ,YAAY,KACtB,QAAO,SAAS,UAAU,QAAQ,SAAS;AAE7C,QAAO;;;;;ACzET,SAAgB,eAAe,QAAkC;CAC/D,MAAM,WAAW,gBAAgB;CACjC,IAAI,YAA+B;AAEnC,QAAO;EACL,YAAY,UAAuB;GACjC,MAAM,SAAS,SAAS;AACxB,OAAI,EAAE,kBAAkB,SAAU;GAElC,MAAM,SAAS,qBAAqB;IAClC;IACA,iBAAiB,OAAO;IACxB,WAAW,SAAS;IACrB,CAAC;AACF,OAAI,WAAW,KAAM;GAErB,MAAM,aAAyB;IAC7B,aAAa;IACb,eAAe,OAAO;IACtB,OAAO,OAAO;IACf;AAED,YAAS,OAAO,WAAW;AAC3B,eAAY;;EAGd,eAAkC;AAChC,UAAO;;EAGT,YAAY,WAAmB,UAAyB,SAAsC;AAC5F,UAAO,SAAS,IAAI,WAAW,UAAU,QAAQ;;EAGnD,gBAA6B;AAC3B,UAAO,SAAS,eAAe;;EAGjC,QAAc;AACZ,YAAS,OAAO;;EAEnB;;;;;ACtDH,SAAgB,cAAc,QAAiC;CAC7D,MAAM,UAAU,QAAQ,WAAW;CACnC,MAAM,kBAAkB,QAAQ,mBAAmB,EAAE;CACrD,MAAM,QAAQ,QAAQ,SAAS;AAE/B,KAAI,CAAC,QACH,QAAO;EACL,gBAAgB;EAChB,oBAAoB;EACpB,eAAe;EAChB;CAGH,MAAM,WAAW,eAAe,EAAE,iBAAiB,CAAC;CACpD,MAAM,+BAAe,IAAI,KAAqC;CAC9D,IAAI,YAAY;CAKhB,SAAS,kBAAkB,WAAyB;AAClD,MAAI,aAAa,IAAI,UAAU,CAAE;EAEjC,MAAM,WAAW,UAAuB;AACtC,YAAS,YAAY,MAAM;AAE3B,OAAI,OAAO;IACT,MAAM,YAAY,SAAS,cAAc;AACzC,QAAI,WAAW,gBAAgB,MAC7B,SAAQ,MAAM,yBAAyB,UAAU;;;AAKvD,WAAS,iBAAiB,WAAW,SAAS,KAAK;AACnD,eAAa,IAAI,WAAW,QAAQ;;CAGtC,SAAS,kBAAkB,WAAyB;EAClD,MAAM,UAAU,aAAa,IAAI,UAAU;AAC3C,MAAI,YAAY,OAAW;AAG3B,MAAI,CAAC,SAAS,eAAe,CAAC,IAAI,UAAU,EAAE;AAC5C,YAAS,oBAAoB,WAAW,SAAS,KAAK;AACtD,gBAAa,OAAO,UAAU;;;AAIlC,QAAO;EACL,GAAG,WAAmB,UAAyB,SAAuC;AACpF,OAAI,UAAW,cAAa;AAE5B,qBAAkB,UAAU;GAC5B,MAAM,QAAQ,SAAS,YAAY,WAAW,UAAU,WAAW,EAAE,CAAC;AAEtE,gBAAa;AACX,WAAO;AACP,sBAAkB,UAAU;;;EAIhC,eAAe;AACb,UAAO,SAAS,cAAc;;EAGhC,UAAgB;AACd,OAAI,UAAW;AACf,eAAY;AAEZ,YAAS,OAAO;AAEhB,QAAK,MAAM,CAAC,WAAW,YAAY,aACjC,UAAS,oBAAoB,WAAW,SAAS,KAAK;AAExD,gBAAa,OAAO;;EAEvB"}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-auto-tracking",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Global user interaction tracking for React.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsdown",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:watch": "vitest",
|
|
28
|
+
"test:coverage": "vitest run --coverage",
|
|
29
|
+
"lint": "oxlint src/",
|
|
30
|
+
"format": "oxfmt --write src/",
|
|
31
|
+
"format:check": "oxfmt --check src/",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"check": "pnpm lint && pnpm format:check && pnpm typecheck"
|
|
34
|
+
},
|
|
35
|
+
"simple-git-hooks": {
|
|
36
|
+
"pre-push": "pnpm check"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"react",
|
|
44
|
+
"tracking",
|
|
45
|
+
"analytics",
|
|
46
|
+
"interaction",
|
|
47
|
+
"fiber"
|
|
48
|
+
],
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@changesets/cli": "^2.29.8",
|
|
51
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
52
|
+
"jsdom": "^28.1.0",
|
|
53
|
+
"oxfmt": "^0.32.0",
|
|
54
|
+
"oxlint": "^1.47.0",
|
|
55
|
+
"simple-git-hooks": "^2.13.1",
|
|
56
|
+
"tsdown": "^0.20.3",
|
|
57
|
+
"typescript": "^5.9.3",
|
|
58
|
+
"vitest": "^4.0.18"
|
|
59
|
+
}
|
|
60
|
+
}
|