react-hook-handlers.test.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. /**
  2. * React handler hooks name the function they wrap.
  3. *
  4. * `const handleSubmit = useCallback(() => {…}, [])` is how nearly every
  5. * handler in a React / React Native component is written, and the arrow is
  6. * anonymous only syntactically — the declarator is the name every
  7. * `onPress={handleSubmit}` and `addListener('x', handleSubmit)` uses. Without
  8. * a node the handler's calls attribute to the component and the trigger of a
  9. * flow (the tap, the native event) has nothing to resolve to.
  10. */
  11. import { describe, it, expect, beforeAll } from 'vitest';
  12. import { extractFromSource } from '../src/extraction';
  13. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  14. beforeAll(async () => {
  15. await initGrammars();
  16. await loadAllGrammars();
  17. });
  18. const refsFrom = (result: ReturnType<typeof extractFromSource>, id: string) =>
  19. result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => r.referenceName);
  20. describe('useCallback handlers', () => {
  21. it('extracts the wrapped arrow as a function named by the declarator, inside the component', () => {
  22. const code = `
  23. import { useCallback, useMemo, useEffect } from 'react'
  24. import { finalize, upload, log } from './api'
  25. export default function ReviewScreen() {
  26. const handleApprove = useCallback(() => {
  27. finalize()
  28. }, [])
  29. const handleZip = useCallback(async (data: { uri: string }) => {
  30. await upload(data.uri)
  31. }, [])
  32. const total = useMemo(() => 1 + 1, [])
  33. useEffect(() => {
  34. log('mounted')
  35. }, [])
  36. return <Button onPress={handleApprove} />
  37. }
  38. `;
  39. const result = extractFromSource('src/app/review.tsx', code);
  40. const fns = result.nodes.filter((n) => n.kind === 'function');
  41. const names = fns.map((n) => n.name);
  42. expect(names).toEqual(expect.arrayContaining(['ReviewScreen', 'handleApprove', 'handleZip']));
  43. // A memo is a value and an effect is anonymous: neither becomes a function.
  44. expect(names).not.toContain('total');
  45. expect(names.filter((n) => n === '<anonymous>')).toEqual([]);
  46. const screen = fns.find((n) => n.name === 'ReviewScreen')!;
  47. const handleZip = fns.find((n) => n.name === 'handleZip')!;
  48. expect(handleZip.qualifiedName).toBe('ReviewScreen::handleZip');
  49. expect(handleZip.startLine).toBe(8);
  50. // The handler's calls are its own; the component keeps only what it does itself.
  51. expect(refsFrom(result, handleZip.id)).toContain('upload');
  52. expect(refsFrom(result, screen.id)).not.toContain('upload');
  53. expect(refsFrom(result, screen.id)).toContain('log');
  54. // Containment: the component contains its handlers.
  55. expect(
  56. result.edges.some((e) => e.kind === 'contains' && e.source === screen.id && e.target === handleZip.id)
  57. ).toBe(true);
  58. // `onPress={handleApprove}` is a function-as-value site: the tap's handler
  59. // is referenced from the component, which is how a Steps picture knows
  60. // the handler is a trigger.
  61. const handleApprove = fns.find((n) => n.name === 'handleApprove')!;
  62. expect(
  63. result.unresolvedReferences.some(
  64. (r) => r.fromNodeId === screen.id && r.referenceKind === 'function_ref' && r.referenceName === 'handleApprove'
  65. )
  66. ).toBe(true);
  67. expect(handleApprove.startLine).toBe(5);
  68. });
  69. it('accepts React.useCallback, function expressions, and useEffectEvent', () => {
  70. const code = `
  71. import React from 'react'
  72. export function Screen() {
  73. const onOpen = React.useCallback(function () { open() }, [])
  74. const onLog = useEffectEvent((url: string) => { track(url) })
  75. return null
  76. }
  77. `;
  78. const result = extractFromSource('src/screen.tsx', code);
  79. const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  80. expect(names).toEqual(expect.arrayContaining(['Screen', 'onOpen', 'onLog']));
  81. });
  82. it('leaves a hook whose first argument is not the bound function alone', () => {
  83. const code = `
  84. export function Screen() {
  85. const value = useState(() => compute())
  86. const cb = useCallback(existingHandler, [])
  87. const [x] = useReducer((s) => s, 0)
  88. return null
  89. }
  90. `;
  91. const result = extractFromSource('src/screen.tsx', code);
  92. const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  93. expect(names).toEqual(['Screen']);
  94. });
  95. it('a handler a hook returns in an object is a function-as-value of the hook', () => {
  96. const code = `
  97. import { useCallback } from 'react'
  98. export function useReviewHandlers() {
  99. const handleApprove = useCallback(() => { finalize() }, [])
  100. const handleRetake = useCallback(() => { retake() }, [])
  101. const count = 1
  102. return { handleApprove, handleRetake, count, extra: helper }
  103. }
  104. function helper() {}
  105. `;
  106. const result = extractFromSource('src/hooks.ts', code);
  107. const hook = result.nodes.find((n) => n.name === 'useReviewHandlers')!;
  108. const fnRefs = result.unresolvedReferences
  109. .filter((r) => r.fromNodeId === hook.id && r.referenceKind === 'function_ref')
  110. .map((r) => r.referenceName)
  111. .sort();
  112. // `count` is a value, not a function defined here: gated out.
  113. expect(fnRefs).toEqual(['handleApprove', 'handleRetake', 'helper']);
  114. });
  115. it('does nothing outside the JS family', () => {
  116. const code = `
  117. func screen() {
  118. let handle = useCallback({ () in finalize() }, [])
  119. }
  120. `;
  121. const result = extractFromSource('Screen.swift', code);
  122. const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  123. expect(names).toEqual(['screen']);
  124. });
  125. });