Skip to content

Commit 2d2f701

Browse files
committed
include component names for react hooks
1 parent 7f84b15 commit 2d2f701

File tree

6 files changed

+150
-16
lines changed

6 files changed

+150
-16
lines changed

src/analyze/javascript/utils/function-finder.js

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,74 @@ const { NODE_TYPES } = require('../constants');
1212
* @returns {string} The function name or 'global' if not in a function
1313
*/
1414
function findWrappingFunction(node, ancestors) {
15+
const REACT_HOOKS = new Set([
16+
'useEffect',
17+
'useLayoutEffect',
18+
'useInsertionEffect',
19+
'useCallback',
20+
'useMemo',
21+
'useReducer',
22+
'useState',
23+
'useImperativeHandle',
24+
'useDeferredValue',
25+
'useTransition'
26+
]);
27+
28+
let hookName = null; // e.g. "useEffect" or "useCallback(handleFoo)"
29+
let componentName = null;
30+
let firstNonHookFunction = null;
31+
1532
// Traverse ancestors from closest to furthest
1633
for (let i = ancestors.length - 1; i >= 0; i--) {
1734
const current = ancestors[i];
18-
const functionName = extractFunctionName(current, node, ancestors[i - 1]);
19-
20-
if (functionName) {
21-
return functionName;
35+
36+
// Detect React hook call (CallExpression with Identifier callee)
37+
if (!hookName && current.type === NODE_TYPES.CALL_EXPRESSION && current.callee && current.callee.type === NODE_TYPES.IDENTIFIER && REACT_HOOKS.has(current.callee.name)) {
38+
hookName = current.callee.name; // store plain hook name; we'll format later if needed
39+
}
40+
41+
// Existing logic to extract named function contexts
42+
const fnName = extractFunctionName(current, node, ancestors[i - 1]);
43+
if (fnName) {
44+
if (REACT_HOOKS.has(stripParens(fnName.split('.')[0]))) {
45+
// fnName itself is a hook signature like "useCallback(handleFoo)" or "useEffect()"
46+
if (!hookName) hookName = fnName;
47+
continue;
48+
}
49+
50+
// First non-hook function up the tree is treated as component/container name
51+
if (!componentName) {
52+
componentName = fnName;
53+
}
54+
55+
// Early exit when we already have both pieces
56+
if (hookName && componentName) {
57+
break;
58+
}
59+
60+
// Save first non-hook function for fallback when no hook detected
61+
if (!firstNonHookFunction) {
62+
firstNonHookFunction = fnName;
63+
}
2264
}
2365
}
24-
66+
67+
// If we detected hook + component, compose them
68+
if (hookName && componentName) {
69+
const formattedHook = typeof hookName === 'string' && hookName.endsWith('()') ? hookName.slice(0, -2) : hookName;
70+
return `${componentName}.${formattedHook}`;
71+
}
72+
73+
// If only hook signature found (no component) – return the hook signature itself
74+
if (hookName) {
75+
return hookName;
76+
}
77+
78+
// Fallbacks to previous behaviour
79+
if (firstNonHookFunction) {
80+
return firstNonHookFunction;
81+
}
82+
2583
return 'global';
2684
}
2785

@@ -118,6 +176,13 @@ function isFunctionNode(node) {
118176
);
119177
}
120178

179+
/**
180+
* Utility to strip trailing parens from simple hook signatures
181+
*/
182+
function stripParens(name) {
183+
return name.endsWith('()') ? name.slice(0, -2) : name;
184+
}
185+
121186
module.exports = {
122187
findWrappingFunction
123188
};

src/analyze/typescript/utils/function-finder.js

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,61 @@ const { isReactHookCall } = require('./type-resolver');
1212
* @returns {string} The function name or 'global' if not in a function
1313
*/
1414
function findWrappingFunction(node) {
15+
// ts is already required at module scope
16+
const REACT_HOOKS = new Set([
17+
'useEffect',
18+
'useLayoutEffect',
19+
'useInsertionEffect',
20+
'useCallback',
21+
'useMemo',
22+
'useReducer',
23+
'useState',
24+
'useImperativeHandle',
25+
'useDeferredValue',
26+
'useTransition'
27+
]);
28+
1529
let current = node;
30+
let hookSignature = null; // e.g. useEffect(), useCallback(handleFoo)
31+
let componentName = null;
32+
let firstNonHookFunction = null;
1633

1734
while (current) {
18-
const functionName = extractFunctionName(current);
19-
20-
if (functionName) {
21-
return functionName;
35+
const fnName = extractFunctionName(current);
36+
37+
if (fnName) {
38+
const baseName = fnName.split('(')[0].replace(/\s+/g, '');
39+
const isHookSig = REACT_HOOKS.has(baseName);
40+
41+
if (isHookSig && !hookSignature) {
42+
hookSignature = fnName; // capture complete signature (may include params)
43+
// Continue searching upward for component
44+
} else if (!isHookSig && !componentName) {
45+
componentName = fnName;
46+
if (hookSignature) {
47+
break; // we have both
48+
}
49+
}
50+
51+
if (!firstNonHookFunction) {
52+
firstNonHookFunction = fnName;
53+
}
2254
}
2355

2456
current = current.parent;
2557
}
2658

27-
return 'global';
59+
if (hookSignature && componentName) {
60+
// Remove trailing () for useEffect etc
61+
const formattedHook = hookSignature.endsWith('()') ? hookSignature.slice(0, -2) : hookSignature;
62+
return `${componentName}.${formattedHook}`;
63+
}
64+
65+
if (hookSignature) {
66+
return hookSignature;
67+
}
68+
69+
return firstNonHookFunction || 'global';
2870
}
2971

3072
/**

tests/analyzeJavaScript.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,4 +409,15 @@ test.describe('analyzeJsFile', () => {
409409
assert.ok(constantEvent);
410410
assert.deepStrictEqual(constantEvent.properties, {});
411411
});
412+
413+
test('should include component name for React hook functions', () => {
414+
const hookFile = path.join(fixturesDir, 'javascript', 'react-hook.js');
415+
const customFunctionSignatures = [parseCustomFunctionSignature('trackUserEvent(EVENT_NAME)')];
416+
const events = analyzeJsFile(hookFile, customFunctionSignatures);
417+
418+
assert.strictEqual(events.length, 1);
419+
const evt = events[0];
420+
assert.strictEqual(evt.eventName, 'ViewedEligibilityResults');
421+
assert.strictEqual(evt.functionName, 'PrePaymentDashboard.useEffect');
422+
});
412423
});

tests/analyzeTypeScript.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,7 @@ test.describe('analyzeTsFile', () => {
588588
const posthogEvent = events.find(e => e.source === 'posthog');
589589
assert.ok(posthogEvent);
590590
assert.strictEqual(posthogEvent.eventName, 'cart_viewed');
591-
assert.strictEqual(posthogEvent.functionName, 'useEffect()');
591+
assert.strictEqual(posthogEvent.functionName, 'ShoppingCart.useEffect');
592592
assert.strictEqual(posthogEvent.line, 89);
593593
assert.deepStrictEqual(posthogEvent.properties, {
594594
item_count: { type: 'number' },
@@ -599,7 +599,7 @@ test.describe('analyzeTsFile', () => {
599599
const segmentEvent = events.find(e => e.source === 'segment' && e.eventName === 'add_to_cart');
600600
assert.ok(segmentEvent);
601601
assert.strictEqual(segmentEvent.eventName, 'add_to_cart');
602-
assert.strictEqual(segmentEvent.functionName, 'useCallback(handleAddToCart)');
602+
assert.strictEqual(segmentEvent.functionName, 'ShoppingCart.useCallback(handleAddToCart)');
603603
assert.strictEqual(segmentEvent.line, 101);
604604
assert.deepStrictEqual(segmentEvent.properties, {
605605
product_id: { type: 'string' },
@@ -611,7 +611,7 @@ test.describe('analyzeTsFile', () => {
611611
const amplitudeEvent = events.find(e => e.source === 'amplitude');
612612
assert.ok(amplitudeEvent);
613613
assert.strictEqual(amplitudeEvent.eventName, 'item_added');
614-
assert.strictEqual(amplitudeEvent.functionName, 'useCallback(handleAddToCart)');
614+
assert.strictEqual(amplitudeEvent.functionName, 'ShoppingCart.useCallback(handleAddToCart)');
615615
assert.strictEqual(amplitudeEvent.line, 108);
616616
assert.deepStrictEqual(amplitudeEvent.properties, {
617617
item_details: {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { useEffect } from 'react';
2+
3+
// Stub implementation to satisfy tests without bundler
4+
function trackUserEvent(eventName, props) {
5+
// no-op
6+
}
7+
8+
function PrePaymentDashboard() {
9+
useEffect(() => {
10+
trackUserEvent('ViewedEligibilityResults');
11+
}, []);
12+
13+
return null;
14+
}
15+
16+
export default PrePaymentDashboard;

tests/fixtures/tracking-schema-all.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -996,7 +996,7 @@ events:
996996
implementations:
997997
- path: typescript-react/main.tsx
998998
line: 89
999-
function: useEffect()
999+
function: ShoppingCart.useEffect
10001000
destination: posthog
10011001
properties:
10021002
item_count:
@@ -1007,7 +1007,7 @@ events:
10071007
implementations:
10081008
- path: typescript-react/main.tsx
10091009
line: 101
1010-
function: useCallback(handleAddToCart)
1010+
function: ShoppingCart.useCallback(handleAddToCart)
10111011
destination: segment
10121012
properties:
10131013
product_id:
@@ -1020,7 +1020,7 @@ events:
10201020
implementations:
10211021
- path: typescript-react/main.tsx
10221022
line: 108
1023-
function: useCallback(handleAddToCart)
1023+
function: ShoppingCart.useCallback(handleAddToCart)
10241024
destination: amplitude
10251025
properties:
10261026
item_details:

0 commit comments

Comments
 (0)