@treelocator/runtime 0.4.6 → 0.5.2
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/dist/dejitter/recorder.d.ts +7 -1
- package/dist/dejitter/recorder.js +64 -1
- package/dist/functions/formatAncestryChain.d.ts +3 -2
- package/dist/functions/formatAncestryChain.js +21 -5
- package/dist/functions/formatAncestryChain.test.js +58 -0
- package/package.json +1 -1
- package/src/dejitter/recorder.ts +67 -3
- package/src/functions/formatAncestryChain.test.ts +28 -0
- package/src/functions/formatAncestryChain.ts +21 -5
|
@@ -47,10 +47,15 @@ export interface DejitterConfig {
|
|
|
47
47
|
outlier: {
|
|
48
48
|
ratioThreshold: number;
|
|
49
49
|
};
|
|
50
|
+
lag: {
|
|
51
|
+
minDelay: number;
|
|
52
|
+
highDelay: number;
|
|
53
|
+
medDelay: number;
|
|
54
|
+
};
|
|
50
55
|
};
|
|
51
56
|
}
|
|
52
57
|
export interface DejitterFinding {
|
|
53
|
-
type: 'jitter' | 'flicker' | 'shiver' | 'jump' | 'stutter' | 'stuck' | 'outlier';
|
|
58
|
+
type: 'jitter' | 'flicker' | 'shiver' | 'jump' | 'stutter' | 'stuck' | 'outlier' | 'lag';
|
|
54
59
|
severity: 'high' | 'medium' | 'low' | 'info';
|
|
55
60
|
elem: string;
|
|
56
61
|
elemLabel: {
|
|
@@ -85,6 +90,7 @@ export interface DejitterAPI {
|
|
|
85
90
|
getRaw(): {
|
|
86
91
|
rawFrames: any[];
|
|
87
92
|
mutations: any[];
|
|
93
|
+
interactions: any[];
|
|
88
94
|
};
|
|
89
95
|
toJSON(): string;
|
|
90
96
|
}
|
|
@@ -48,6 +48,11 @@ export function createDejitterRecorder() {
|
|
|
48
48
|
},
|
|
49
49
|
outlier: {
|
|
50
50
|
ratioThreshold: 3
|
|
51
|
+
},
|
|
52
|
+
lag: {
|
|
53
|
+
minDelay: 50,
|
|
54
|
+
highDelay: 200,
|
|
55
|
+
medDelay: 100
|
|
51
56
|
}
|
|
52
57
|
}
|
|
53
58
|
};
|
|
@@ -59,12 +64,14 @@ export function createDejitterRecorder() {
|
|
|
59
64
|
let rafId = null;
|
|
60
65
|
let stopTimer = null;
|
|
61
66
|
let mutationObserver = null;
|
|
67
|
+
let interactionAbort = null;
|
|
62
68
|
let onStopCallbacks = [];
|
|
63
69
|
let lastSeen = new Map();
|
|
64
70
|
let rawFrames = [];
|
|
65
71
|
let lastChangeTime = 0;
|
|
66
72
|
let hasSeenChange = false;
|
|
67
73
|
let mutations = [];
|
|
74
|
+
let interactions = [];
|
|
68
75
|
let nextElemId = 0;
|
|
69
76
|
function elemId(el) {
|
|
70
77
|
if (!el.__dj_id) {
|
|
@@ -210,6 +217,24 @@ export function createDejitterRecorder() {
|
|
|
210
217
|
characterData: true
|
|
211
218
|
});
|
|
212
219
|
}
|
|
220
|
+
function startInteractionListeners() {
|
|
221
|
+
interactionAbort = new AbortController();
|
|
222
|
+
const opts = {
|
|
223
|
+
capture: true,
|
|
224
|
+
signal: interactionAbort.signal
|
|
225
|
+
};
|
|
226
|
+
const handler = e => {
|
|
227
|
+
if (!recording) return;
|
|
228
|
+
const t = Math.round(performance.now() - startTime);
|
|
229
|
+
interactions.push({
|
|
230
|
+
t,
|
|
231
|
+
type: e.type
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
for (const evt of ['click', 'pointerdown', 'keydown']) {
|
|
235
|
+
document.addEventListener(evt, handler, opts);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
213
238
|
|
|
214
239
|
// --- Downsampling ---
|
|
215
240
|
|
|
@@ -736,6 +761,39 @@ export function createDejitterRecorder() {
|
|
|
736
761
|
}
|
|
737
762
|
return findings;
|
|
738
763
|
}
|
|
764
|
+
function detectLagFindings(elements) {
|
|
765
|
+
const findings = [];
|
|
766
|
+
if (interactions.length === 0 || rawFrames.length === 0) return findings;
|
|
767
|
+
const lt = config.thresholds.lag;
|
|
768
|
+
for (const interaction of interactions) {
|
|
769
|
+
let firstFrame = null;
|
|
770
|
+
for (const frame of rawFrames) {
|
|
771
|
+
if (frame.t > interaction.t) {
|
|
772
|
+
firstFrame = frame;
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (!firstFrame) continue;
|
|
777
|
+
const delay = firstFrame.t - interaction.t;
|
|
778
|
+
if (delay < lt.minDelay) continue;
|
|
779
|
+
const severity = delay >= lt.highDelay ? 'high' : delay >= lt.medDelay ? 'medium' : 'low';
|
|
780
|
+
const firstChange = firstFrame.changes[0];
|
|
781
|
+
const label = firstChange && elements[firstChange.id] || {
|
|
782
|
+
tag: '?',
|
|
783
|
+
cls: '',
|
|
784
|
+
text: ''
|
|
785
|
+
};
|
|
786
|
+
findings.push(makeFinding('lag', severity, firstChange?.id || '?', label, interaction.type, `${delay}ms between ${interaction.type} at t=${interaction.t}ms and first visual change at t=${firstFrame.t}ms`, {
|
|
787
|
+
lag: {
|
|
788
|
+
interactionType: interaction.type,
|
|
789
|
+
interactionT: interaction.t,
|
|
790
|
+
firstChangeT: firstFrame.t,
|
|
791
|
+
delay
|
|
792
|
+
}
|
|
793
|
+
}));
|
|
794
|
+
}
|
|
795
|
+
return findings;
|
|
796
|
+
}
|
|
739
797
|
function deduplicateShivers(findings) {
|
|
740
798
|
const shiverFindings = findings.filter(f => f.type === 'shiver');
|
|
741
799
|
const otherFindings = findings.filter(f => f.type !== 'shiver');
|
|
@@ -769,6 +827,7 @@ export function createDejitterRecorder() {
|
|
|
769
827
|
findings = findings.concat(detectJumpFindings(propStats, elements));
|
|
770
828
|
findings = findings.concat(detectStutterFindings(propStats, elements));
|
|
771
829
|
findings = findings.concat(detectStuckFindings(propStats, elements));
|
|
830
|
+
findings = findings.concat(detectLagFindings(elements));
|
|
772
831
|
findings = deduplicateShivers(findings);
|
|
773
832
|
const sevOrder = {
|
|
774
833
|
high: 0,
|
|
@@ -810,6 +869,7 @@ export function createDejitterRecorder() {
|
|
|
810
869
|
start() {
|
|
811
870
|
rawFrames = [];
|
|
812
871
|
mutations = [];
|
|
872
|
+
interactions = [];
|
|
813
873
|
lastSeen = new Map();
|
|
814
874
|
nextElemId = 0;
|
|
815
875
|
recording = true;
|
|
@@ -818,6 +878,7 @@ export function createDejitterRecorder() {
|
|
|
818
878
|
hasSeenChange = false;
|
|
819
879
|
onStopCallbacks = [];
|
|
820
880
|
startMutationObserver();
|
|
881
|
+
startInteractionListeners();
|
|
821
882
|
rafId = requestAnimationFrame(loop);
|
|
822
883
|
if (config.maxDuration > 0) {
|
|
823
884
|
stopTimer = setTimeout(() => api.stop(), config.maxDuration);
|
|
@@ -830,6 +891,7 @@ export function createDejitterRecorder() {
|
|
|
830
891
|
if (rafId) cancelAnimationFrame(rafId);
|
|
831
892
|
if (stopTimer) clearTimeout(stopTimer);
|
|
832
893
|
mutationObserver?.disconnect();
|
|
894
|
+
interactionAbort?.abort();
|
|
833
895
|
const msg = `Stopped. ${rawFrames.length} raw frames, ${mutations.length} mutation events.`;
|
|
834
896
|
for (const cb of onStopCallbacks) {
|
|
835
897
|
try {
|
|
@@ -892,7 +954,8 @@ export function createDejitterRecorder() {
|
|
|
892
954
|
getRaw() {
|
|
893
955
|
return {
|
|
894
956
|
rawFrames,
|
|
895
|
-
mutations
|
|
957
|
+
mutations,
|
|
958
|
+
interactions
|
|
896
959
|
};
|
|
897
960
|
},
|
|
898
961
|
toJSON() {
|
|
@@ -20,8 +20,9 @@ export interface AncestryItem {
|
|
|
20
20
|
export declare function collectAncestry(node: TreeNode): AncestryItem[];
|
|
21
21
|
/**
|
|
22
22
|
* Truncate ancestry to keep only the local context.
|
|
23
|
-
* - If the clicked element has no
|
|
24
|
-
* - If the clicked element has a
|
|
23
|
+
* - If the clicked element has no file: keep up to the first ancestor with a file.
|
|
24
|
+
* - If the clicked element has a file: keep up to the first ancestor with a DIFFERENT file.
|
|
25
|
+
* Checks both client filePath and serverComponents for file info.
|
|
25
26
|
* The ancestry array is bottom-up: index 0 = clicked element, last = root.
|
|
26
27
|
*/
|
|
27
28
|
export declare function truncateAtFirstFile(items: AncestryItem[]): AncestryItem[];
|
|
@@ -122,25 +122,41 @@ function getInnermostNamedComponent(item) {
|
|
|
122
122
|
return undefined;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Get the effective file path from an AncestryItem, checking both
|
|
127
|
+
* client filePath and serverComponents (Next.js RSC, Phoenix, etc.).
|
|
128
|
+
*/
|
|
129
|
+
function getItemFilePath(item) {
|
|
130
|
+
if (item.filePath) return item.filePath;
|
|
131
|
+
if (item.serverComponents && item.serverComponents.length > 0) {
|
|
132
|
+
const comp = item.serverComponents.find(sc => sc.type === "component");
|
|
133
|
+
if (comp) return comp.filePath;
|
|
134
|
+
return item.serverComponents[0].filePath;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
125
139
|
/**
|
|
126
140
|
* Truncate ancestry to keep only the local context.
|
|
127
|
-
* - If the clicked element has no
|
|
128
|
-
* - If the clicked element has a
|
|
141
|
+
* - If the clicked element has no file: keep up to the first ancestor with a file.
|
|
142
|
+
* - If the clicked element has a file: keep up to the first ancestor with a DIFFERENT file.
|
|
143
|
+
* Checks both client filePath and serverComponents for file info.
|
|
129
144
|
* The ancestry array is bottom-up: index 0 = clicked element, last = root.
|
|
130
145
|
*/
|
|
131
146
|
export function truncateAtFirstFile(items) {
|
|
132
147
|
if (items.length === 0) return items;
|
|
133
|
-
const clickedFile = items[0]
|
|
148
|
+
const clickedFile = getItemFilePath(items[0]);
|
|
134
149
|
if (!clickedFile) {
|
|
135
150
|
// Clicked element has no file: find first ancestor with any file
|
|
136
|
-
const firstWithFile = items.findIndex(item => item
|
|
151
|
+
const firstWithFile = items.findIndex(item => getItemFilePath(item));
|
|
137
152
|
if (firstWithFile === -1) return items;
|
|
138
153
|
return items.slice(0, firstWithFile + 1);
|
|
139
154
|
}
|
|
140
155
|
|
|
141
156
|
// Clicked element has a file: find first ancestor with a different file
|
|
142
157
|
for (let i = 1; i < items.length; i++) {
|
|
143
|
-
|
|
158
|
+
const ancestorFile = getItemFilePath(items[i]);
|
|
159
|
+
if (ancestorFile && ancestorFile !== clickedFile) {
|
|
144
160
|
return items.slice(0, i + 1);
|
|
145
161
|
}
|
|
146
162
|
}
|
|
@@ -367,6 +367,64 @@ describe("formatAncestryChain", () => {
|
|
|
367
367
|
const result = truncateAtFirstFile(items);
|
|
368
368
|
expect(result).toEqual(items);
|
|
369
369
|
});
|
|
370
|
+
it("uses serverComponents file path when filePath is missing", () => {
|
|
371
|
+
const items = [{
|
|
372
|
+
elementName: "div",
|
|
373
|
+
componentName: "TurnActivityBox",
|
|
374
|
+
serverComponents: [{
|
|
375
|
+
name: "TurnActivityBox",
|
|
376
|
+
filePath: "components/MessageRow.tsx",
|
|
377
|
+
line: 921,
|
|
378
|
+
type: "component"
|
|
379
|
+
}]
|
|
380
|
+
}, {
|
|
381
|
+
elementName: "div",
|
|
382
|
+
componentName: "MessageRow",
|
|
383
|
+
serverComponents: [{
|
|
384
|
+
name: "MessageRow",
|
|
385
|
+
filePath: "components/chat/ChatViewport.tsx",
|
|
386
|
+
line: 917,
|
|
387
|
+
type: "component"
|
|
388
|
+
}]
|
|
389
|
+
}, {
|
|
390
|
+
elementName: "main",
|
|
391
|
+
componentName: "Home",
|
|
392
|
+
serverComponents: [{
|
|
393
|
+
name: "Home",
|
|
394
|
+
filePath: "app/page.tsx",
|
|
395
|
+
line: 817,
|
|
396
|
+
type: "component"
|
|
397
|
+
}]
|
|
398
|
+
}];
|
|
399
|
+
const result = truncateAtFirstFile(items);
|
|
400
|
+
expect(result).toEqual([items[0], items[1]]);
|
|
401
|
+
});
|
|
402
|
+
it("uses serverComponents when clicked element has no filePath but has serverComponents", () => {
|
|
403
|
+
const items = [{
|
|
404
|
+
elementName: "span",
|
|
405
|
+
componentName: "Button"
|
|
406
|
+
}, {
|
|
407
|
+
elementName: "div",
|
|
408
|
+
componentName: "Card",
|
|
409
|
+
serverComponents: [{
|
|
410
|
+
name: "Card",
|
|
411
|
+
filePath: "src/Card.tsx",
|
|
412
|
+
line: 10,
|
|
413
|
+
type: "component"
|
|
414
|
+
}]
|
|
415
|
+
}, {
|
|
416
|
+
elementName: "div",
|
|
417
|
+
componentName: "App",
|
|
418
|
+
serverComponents: [{
|
|
419
|
+
name: "App",
|
|
420
|
+
filePath: "src/App.tsx",
|
|
421
|
+
line: 1,
|
|
422
|
+
type: "component"
|
|
423
|
+
}]
|
|
424
|
+
}];
|
|
425
|
+
const result = truncateAtFirstFile(items);
|
|
426
|
+
expect(result).toEqual([items[0], items[1]]);
|
|
427
|
+
});
|
|
370
428
|
it("returns empty array for empty input", () => {
|
|
371
429
|
expect(truncateAtFirstFile([])).toEqual([]);
|
|
372
430
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@treelocator/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "TreeLocatorJS runtime for component ancestry tracking. Alt+click any element to copy its component tree to clipboard. Exposes window.__treelocator__ API for browser automation (Playwright, Puppeteer, Selenium, Cypress).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"locator",
|
package/src/dejitter/recorder.ts
CHANGED
|
@@ -20,11 +20,12 @@ export interface DejitterConfig {
|
|
|
20
20
|
stutter: { velocityRatio: number; maxFrames: number; minVelocity: number };
|
|
21
21
|
stuck: { minStillFrames: number; maxDelta: number; minSurroundingVelocity: number; highDuration: number; medDuration: number };
|
|
22
22
|
outlier: { ratioThreshold: number };
|
|
23
|
+
lag: { minDelay: number; highDelay: number; medDelay: number };
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export interface DejitterFinding {
|
|
27
|
-
type: 'jitter' | 'flicker' | 'shiver' | 'jump' | 'stutter' | 'stuck' | 'outlier';
|
|
28
|
+
type: 'jitter' | 'flicker' | 'shiver' | 'jump' | 'stutter' | 'stuck' | 'outlier' | 'lag';
|
|
28
29
|
severity: 'high' | 'medium' | 'low' | 'info';
|
|
29
30
|
elem: string;
|
|
30
31
|
elemLabel: { tag: string; cls: string; text: string };
|
|
@@ -50,7 +51,7 @@ export interface DejitterAPI {
|
|
|
50
51
|
getData(): any;
|
|
51
52
|
summary(json?: boolean): string | DejitterSummary;
|
|
52
53
|
findings(json?: boolean): string | DejitterFinding[];
|
|
53
|
-
getRaw(): { rawFrames: any[]; mutations: any[] };
|
|
54
|
+
getRaw(): { rawFrames: any[]; mutations: any[]; interactions: any[] };
|
|
54
55
|
toJSON(): string;
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -70,6 +71,7 @@ export function createDejitterRecorder(): DejitterAPI {
|
|
|
70
71
|
stutter: { velocityRatio: 0.3, maxFrames: 3, minVelocity: 0.5 },
|
|
71
72
|
stuck: { minStillFrames: 3, maxDelta: 0.5, minSurroundingVelocity: 1, highDuration: 500, medDuration: 200 },
|
|
72
73
|
outlier: { ratioThreshold: 3 },
|
|
74
|
+
lag: { minDelay: 50, highDelay: 200, medDelay: 100 },
|
|
73
75
|
},
|
|
74
76
|
};
|
|
75
77
|
|
|
@@ -79,6 +81,7 @@ export function createDejitterRecorder(): DejitterAPI {
|
|
|
79
81
|
let rafId: number | null = null;
|
|
80
82
|
let stopTimer: ReturnType<typeof setTimeout> | null = null;
|
|
81
83
|
let mutationObserver: MutationObserver | null = null;
|
|
84
|
+
let interactionAbort: AbortController | null = null;
|
|
82
85
|
let onStopCallbacks: Array<() => void> = [];
|
|
83
86
|
|
|
84
87
|
let lastSeen = new Map<string, Record<string, any>>();
|
|
@@ -86,6 +89,7 @@ export function createDejitterRecorder(): DejitterAPI {
|
|
|
86
89
|
let lastChangeTime = 0;
|
|
87
90
|
let hasSeenChange = false;
|
|
88
91
|
let mutations: any[] = [];
|
|
92
|
+
let interactions: Array<{ t: number; type: string }> = [];
|
|
89
93
|
let nextElemId = 0;
|
|
90
94
|
|
|
91
95
|
function elemId(el: any): string {
|
|
@@ -212,6 +216,19 @@ export function createDejitterRecorder(): DejitterAPI {
|
|
|
212
216
|
});
|
|
213
217
|
}
|
|
214
218
|
|
|
219
|
+
function startInteractionListeners(): void {
|
|
220
|
+
interactionAbort = new AbortController();
|
|
221
|
+
const opts: AddEventListenerOptions & { signal: AbortSignal } = { capture: true, signal: interactionAbort.signal };
|
|
222
|
+
const handler = (e: Event): void => {
|
|
223
|
+
if (!recording) return;
|
|
224
|
+
const t = Math.round(performance.now() - startTime);
|
|
225
|
+
interactions.push({ t, type: e.type });
|
|
226
|
+
};
|
|
227
|
+
for (const evt of ['click', 'pointerdown', 'keydown'] as const) {
|
|
228
|
+
document.addEventListener(evt, handler, opts);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
215
232
|
// --- Downsampling ---
|
|
216
233
|
|
|
217
234
|
function downsample(): any[] {
|
|
@@ -778,6 +795,49 @@ export function createDejitterRecorder(): DejitterAPI {
|
|
|
778
795
|
return findings;
|
|
779
796
|
}
|
|
780
797
|
|
|
798
|
+
function detectLagFindings(elements: Record<string, any>): DejitterFinding[] {
|
|
799
|
+
const findings: DejitterFinding[] = [];
|
|
800
|
+
if (interactions.length === 0 || rawFrames.length === 0) return findings;
|
|
801
|
+
|
|
802
|
+
const lt = config.thresholds.lag;
|
|
803
|
+
|
|
804
|
+
for (const interaction of interactions) {
|
|
805
|
+
let firstFrame: { t: number; changes: any[] } | null = null;
|
|
806
|
+
for (const frame of rawFrames) {
|
|
807
|
+
if (frame.t > interaction.t) {
|
|
808
|
+
firstFrame = frame;
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (!firstFrame) continue;
|
|
814
|
+
|
|
815
|
+
const delay = firstFrame.t - interaction.t;
|
|
816
|
+
if (delay < lt.minDelay) continue;
|
|
817
|
+
|
|
818
|
+
const severity = delay >= lt.highDelay ? 'high' : delay >= lt.medDelay ? 'medium' : 'low';
|
|
819
|
+
|
|
820
|
+
const firstChange = firstFrame.changes[0];
|
|
821
|
+
const label = (firstChange && elements[firstChange.id]) || { tag: '?', cls: '', text: '' };
|
|
822
|
+
|
|
823
|
+
findings.push(makeFinding(
|
|
824
|
+
'lag', severity,
|
|
825
|
+
firstChange?.id || '?', label, interaction.type,
|
|
826
|
+
`${delay}ms between ${interaction.type} at t=${interaction.t}ms and first visual change at t=${firstFrame.t}ms`,
|
|
827
|
+
{
|
|
828
|
+
lag: {
|
|
829
|
+
interactionType: interaction.type,
|
|
830
|
+
interactionT: interaction.t,
|
|
831
|
+
firstChangeT: firstFrame.t,
|
|
832
|
+
delay,
|
|
833
|
+
},
|
|
834
|
+
}
|
|
835
|
+
));
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return findings;
|
|
839
|
+
}
|
|
840
|
+
|
|
781
841
|
function deduplicateShivers(findings: DejitterFinding[]): DejitterFinding[] {
|
|
782
842
|
const shiverFindings = findings.filter((f) => f.type === 'shiver');
|
|
783
843
|
const otherFindings = findings.filter((f) => f.type !== 'shiver');
|
|
@@ -814,6 +874,7 @@ export function createDejitterRecorder(): DejitterAPI {
|
|
|
814
874
|
findings = findings.concat(detectJumpFindings(propStats, elements));
|
|
815
875
|
findings = findings.concat(detectStutterFindings(propStats, elements));
|
|
816
876
|
findings = findings.concat(detectStuckFindings(propStats, elements));
|
|
877
|
+
findings = findings.concat(detectLagFindings(elements));
|
|
817
878
|
findings = deduplicateShivers(findings);
|
|
818
879
|
|
|
819
880
|
const sevOrder: Record<string, number> = { high: 0, medium: 1, low: 2, info: 3 };
|
|
@@ -842,6 +903,7 @@ export function createDejitterRecorder(): DejitterAPI {
|
|
|
842
903
|
start() {
|
|
843
904
|
rawFrames = [];
|
|
844
905
|
mutations = [];
|
|
906
|
+
interactions = [];
|
|
845
907
|
lastSeen = new Map();
|
|
846
908
|
nextElemId = 0;
|
|
847
909
|
recording = true;
|
|
@@ -851,6 +913,7 @@ export function createDejitterRecorder(): DejitterAPI {
|
|
|
851
913
|
onStopCallbacks = [];
|
|
852
914
|
|
|
853
915
|
startMutationObserver();
|
|
916
|
+
startInteractionListeners();
|
|
854
917
|
rafId = requestAnimationFrame(loop);
|
|
855
918
|
|
|
856
919
|
if (config.maxDuration > 0) {
|
|
@@ -865,6 +928,7 @@ export function createDejitterRecorder(): DejitterAPI {
|
|
|
865
928
|
if (rafId) cancelAnimationFrame(rafId);
|
|
866
929
|
if (stopTimer) clearTimeout(stopTimer);
|
|
867
930
|
mutationObserver?.disconnect();
|
|
931
|
+
interactionAbort?.abort();
|
|
868
932
|
|
|
869
933
|
const msg = `Stopped. ${rawFrames.length} raw frames, ${mutations.length} mutation events.`;
|
|
870
934
|
|
|
@@ -921,7 +985,7 @@ export function createDejitterRecorder(): DejitterAPI {
|
|
|
921
985
|
},
|
|
922
986
|
|
|
923
987
|
getRaw() {
|
|
924
|
-
return { rawFrames, mutations };
|
|
988
|
+
return { rawFrames, mutations, interactions };
|
|
925
989
|
},
|
|
926
990
|
|
|
927
991
|
toJSON() {
|
|
@@ -336,6 +336,34 @@ describe("formatAncestryChain", () => {
|
|
|
336
336
|
expect(result).toEqual(items);
|
|
337
337
|
});
|
|
338
338
|
|
|
339
|
+
it("uses serverComponents file path when filePath is missing", () => {
|
|
340
|
+
const items: AncestryItem[] = [
|
|
341
|
+
{ elementName: "div", componentName: "TurnActivityBox", serverComponents: [{ name: "TurnActivityBox", filePath: "components/MessageRow.tsx", line: 921, type: "component" }] },
|
|
342
|
+
{ elementName: "div", componentName: "MessageRow", serverComponents: [{ name: "MessageRow", filePath: "components/chat/ChatViewport.tsx", line: 917, type: "component" }] },
|
|
343
|
+
{ elementName: "main", componentName: "Home", serverComponents: [{ name: "Home", filePath: "app/page.tsx", line: 817, type: "component" }] },
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
const result = truncateAtFirstFile(items);
|
|
347
|
+
expect(result).toEqual([
|
|
348
|
+
items[0],
|
|
349
|
+
items[1],
|
|
350
|
+
]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("uses serverComponents when clicked element has no filePath but has serverComponents", () => {
|
|
354
|
+
const items: AncestryItem[] = [
|
|
355
|
+
{ elementName: "span", componentName: "Button" },
|
|
356
|
+
{ elementName: "div", componentName: "Card", serverComponents: [{ name: "Card", filePath: "src/Card.tsx", line: 10, type: "component" }] },
|
|
357
|
+
{ elementName: "div", componentName: "App", serverComponents: [{ name: "App", filePath: "src/App.tsx", line: 1, type: "component" }] },
|
|
358
|
+
];
|
|
359
|
+
|
|
360
|
+
const result = truncateAtFirstFile(items);
|
|
361
|
+
expect(result).toEqual([
|
|
362
|
+
items[0],
|
|
363
|
+
items[1],
|
|
364
|
+
]);
|
|
365
|
+
});
|
|
366
|
+
|
|
339
367
|
it("returns empty array for empty input", () => {
|
|
340
368
|
expect(truncateAtFirstFile([])).toEqual([]);
|
|
341
369
|
});
|
|
@@ -164,27 +164,43 @@ function getInnermostNamedComponent(item: AncestryItem | null | undefined): stri
|
|
|
164
164
|
return undefined;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Get the effective file path from an AncestryItem, checking both
|
|
169
|
+
* client filePath and serverComponents (Next.js RSC, Phoenix, etc.).
|
|
170
|
+
*/
|
|
171
|
+
function getItemFilePath(item: AncestryItem): string | undefined {
|
|
172
|
+
if (item.filePath) return item.filePath;
|
|
173
|
+
if (item.serverComponents && item.serverComponents.length > 0) {
|
|
174
|
+
const comp = item.serverComponents.find((sc) => sc.type === "component");
|
|
175
|
+
if (comp) return comp.filePath;
|
|
176
|
+
return item.serverComponents[0]!.filePath;
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
167
181
|
/**
|
|
168
182
|
* Truncate ancestry to keep only the local context.
|
|
169
|
-
* - If the clicked element has no
|
|
170
|
-
* - If the clicked element has a
|
|
183
|
+
* - If the clicked element has no file: keep up to the first ancestor with a file.
|
|
184
|
+
* - If the clicked element has a file: keep up to the first ancestor with a DIFFERENT file.
|
|
185
|
+
* Checks both client filePath and serverComponents for file info.
|
|
171
186
|
* The ancestry array is bottom-up: index 0 = clicked element, last = root.
|
|
172
187
|
*/
|
|
173
188
|
export function truncateAtFirstFile(items: AncestryItem[]): AncestryItem[] {
|
|
174
189
|
if (items.length === 0) return items;
|
|
175
190
|
|
|
176
|
-
const clickedFile = items[0]
|
|
191
|
+
const clickedFile = getItemFilePath(items[0]!);
|
|
177
192
|
|
|
178
193
|
if (!clickedFile) {
|
|
179
194
|
// Clicked element has no file: find first ancestor with any file
|
|
180
|
-
const firstWithFile = items.findIndex((item) => item
|
|
195
|
+
const firstWithFile = items.findIndex((item) => getItemFilePath(item));
|
|
181
196
|
if (firstWithFile === -1) return items;
|
|
182
197
|
return items.slice(0, firstWithFile + 1);
|
|
183
198
|
}
|
|
184
199
|
|
|
185
200
|
// Clicked element has a file: find first ancestor with a different file
|
|
186
201
|
for (let i = 1; i < items.length; i++) {
|
|
187
|
-
|
|
202
|
+
const ancestorFile = getItemFilePath(items[i]!);
|
|
203
|
+
if (ancestorFile && ancestorFile !== clickedFile) {
|
|
188
204
|
return items.slice(0, i + 1);
|
|
189
205
|
}
|
|
190
206
|
}
|