1
0

react-hoc-component.test.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  2. import * as fs from 'node:fs';
  3. import * as path from 'node:path';
  4. import * as os from 'node:os';
  5. import { CodeGraph } from '../src';
  6. /**
  7. * #841 — React components declared via an HOC wrapper
  8. * (`const Button = forwardRef(...)`, `memo(...)`, `styled.x\`…\``) were indexed
  9. * as plain `constant` nodes, so their JSX usages (`<Button/>`) got no render
  10. * edge and `getCallers` / `getImpactRadius` returned empty — a dangerous silent
  11. * false negative for every shadcn/ui-style design system. They must now be
  12. * `component` nodes that receive jsx-render edges like function components do.
  13. */
  14. describe('React HOC-wrapped component recognition (#841)', () => {
  15. let dir: string;
  16. let cg: any;
  17. beforeEach(() => {
  18. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'react-hoc-'));
  19. fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}');
  20. });
  21. afterEach(() => {
  22. cg?.close?.();
  23. fs.rmSync(dir, { recursive: true, force: true });
  24. });
  25. async function index() {
  26. cg = await CodeGraph.init(dir, { silent: true });
  27. await cg.indexAll();
  28. return (cg as any).db.db;
  29. }
  30. const kindsOf = (db: any, name: string): string[] =>
  31. db
  32. .prepare('SELECT kind FROM nodes WHERE name=? ORDER BY kind')
  33. .all(name)
  34. .map((r: any) => r.kind);
  35. it('classifies forwardRef / memo / styled consts as component nodes (not constant)', async () => {
  36. fs.writeFileSync(
  37. path.join(dir, 'ui.tsx'),
  38. `import * as React from 'react';
  39. import styled from 'styled-components';
  40. export const Button = React.forwardRef<HTMLButtonElement, {}>((props, ref) => <button ref={ref} {...props} />);
  41. export const Bare = forwardRef((props, ref) => <span ref={ref} />);
  42. export const Card = memo((props: { t: string }) => <div>{props.t}</div>);
  43. export const Named = memo(function Named(props: { t: string }) { return <div>{props.t}</div>; });
  44. export const Boxed = styled.div\`color: red;\`;
  45. export const Wrapped = styled(Button)\`padding: 4px;\`;
  46. export const Rewrapped = memo(Button);
  47. `
  48. );
  49. const db = await index();
  50. for (const name of ['Button', 'Bare', 'Card', 'Named', 'Boxed', 'Wrapped', 'Rewrapped']) {
  51. expect(kindsOf(db, name), `${name} should be a component`).toContain('component');
  52. // The bug was that these stayed plain constants.
  53. expect(kindsOf(db, name), `${name} should not remain a constant`).not.toContain('constant');
  54. }
  55. });
  56. it('emits jsx-render edges so getCallers/getImpactRadius resolve a forwardRef component', async () => {
  57. fs.writeFileSync(
  58. path.join(dir, 'button.tsx'),
  59. `import * as React from 'react';
  60. export const Button = React.forwardRef<HTMLButtonElement, {}>((props, ref) => <button ref={ref} {...props} />);
  61. `
  62. );
  63. fs.writeFileSync(
  64. path.join(dir, 'page.tsx'),
  65. `import { Button } from './button';
  66. export function Page() {
  67. return <Button>Click</Button>;
  68. }
  69. `
  70. );
  71. const db = await index();
  72. // The render edge exists and is the synthesized jsx-render kind.
  73. const edgeRows = db
  74. .prepare(
  75. `SELECT s.name caller FROM edges e
  76. JOIN nodes s ON s.id = e.source
  77. JOIN nodes t ON t.id = e.target
  78. WHERE json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
  79. AND t.kind = 'component' AND t.name = 'Button'`
  80. )
  81. .all();
  82. expect(edgeRows.map((r: any) => r.caller)).toContain('Page');
  83. // ...and it surfaces through the public callers API (the issue's symptom:
  84. // "No callers found" before the fix).
  85. const buttonId = db
  86. .prepare("SELECT id FROM nodes WHERE name='Button' AND kind='component'")
  87. .get().id as string;
  88. const callers = cg.getCallers(buttonId).map((c: any) => c.node.name);
  89. expect(callers).toContain('Page');
  90. });
  91. it('captures the inner render-fn body callees under the component', async () => {
  92. fs.writeFileSync(
  93. path.join(dir, 'widget.tsx'),
  94. `import * as React from 'react';
  95. function useThing() { return 1; }
  96. export const Widget = React.forwardRef((props, ref) => {
  97. const v = useThing();
  98. return <div ref={ref}>{v}</div>;
  99. });
  100. `
  101. );
  102. const db = await index();
  103. const rows = db
  104. .prepare(
  105. `SELECT t.name FROM edges e
  106. JOIN nodes s ON s.id = e.source
  107. JOIN nodes t ON t.id = e.target
  108. WHERE s.name = 'Widget' AND s.kind = 'component'
  109. AND e.kind = 'calls' AND t.name = 'useThing'`
  110. )
  111. .all();
  112. expect(rows.length).toBeGreaterThanOrEqual(1);
  113. });
  114. it('does not misclassify non-component PascalCase consts (precision)', async () => {
  115. fs.writeFileSync(
  116. path.join(dir, 'controls.tsx'),
  117. `import * as React from 'react';
  118. const cache = memo(expensiveFn);
  119. export const Config = loadConfig();
  120. export const Client = new ApiClient();
  121. export const Styles = styledHelper();
  122. export const Total = [1, 2].reduce((a, b) => a + b, 0);
  123. export const Theme = { color: 'red' };
  124. `
  125. );
  126. const db = await index();
  127. for (const name of ['Config', 'Client', 'Styles', 'Total', 'Theme']) {
  128. expect(kindsOf(db, name), `${name} must stay a constant`).toContain('constant');
  129. expect(kindsOf(db, name), `${name} must not be a component`).not.toContain('component');
  130. }
  131. // A lowercase-named memo() result is a memoization util, not a component.
  132. expect(kindsOf(db, 'cache')).not.toContain('component');
  133. });
  134. });