Files
codegraph/__tests__/sync-jsx-import-forwarding.test.ts
Colby Mchenry 8206c8c271 fix(react): a JSX tag imported from the project renders what its module exports (#2563)
The JSX render pass matched a tag by name and used the file's imports only
to break ties, so an index that forwards another module's component
declared no candidate and the tag took a namesake elsewhere: outline's
Sidebar/index.ts is `import Sidebar from "./App"; export default Sidebar`,
and <Sidebar> rendered Aside.tsx's styled Sidebar, not the AppSidebar that
App.tsx exports as observer(AppSidebar).

A tag imported from a module of the project now renders what that module
exports under the imported name, read by exportedComponent, the reader
lazy routes use: through an index or barrel, past a wrapper call. Two rules
join that reader, for routes too: a const holding another binding is that
binding (`const Avatar = AvatarWithHoverCard; export { Avatar }`; a let may
be reassigned, so it stays), and a memo wrapper's bodyless component node is
the component it wraps (`const MemoizedBlurhash = memo(Blurhash)`). A module
whose export is no component node (`export default () => ...`) links nothing
rather than a namesake. Sync records every file a tag's import is read
through as a synthesis input, so rewiring only the index redraws the tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:22:28 +00:00

177 lines
7.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A sync redraws a JSX tag when a module its import is read through changes.
*
* A tag imported from a module of the project renders what that module
* exports under the name, through an `index` that forwards it — outline's
* `Sidebar/index.ts` is `import Sidebar from './App'; export default
* Sidebar` — and past a wrapper around it (`export default
* observer(AppSidebar)`). So the tag's edge depends on files that hold no
* markup and no synthesized edge of their own: rewiring the index, or the
* default export behind it, must redraw the tag, as a fresh index would draw
* it, though the file that renders the tag is untouched.
*
* Every case compares the synced graph with a fresh index of the same files in
* a folder of its own: indexing again over an existing index skips its
* unchanged files, so it is no stand-in for one.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
type Files = Record<string, string | null>;
const pkg = JSON.stringify({ name: 'app', dependencies: { react: '^18', 'mobx-react': '^9' } });
/** Two pages that render `<Sidebar>` through the same forwarding index. */
const BASE: Files = {
'package.json': pkg,
'src/components/Sidebar/Aside.tsx': 'export function Sidebar() {\n return <aside />;\n}\n',
'src/components/Sidebar/App.tsx':
"import { observer } from 'mobx-react';\n\nfunction AppSidebar() {\n return <nav />;\n}\n\nexport default observer(AppSidebar);\n",
'src/components/Sidebar/Shared.tsx':
"import { observer } from 'mobx-react';\n\nfunction SharedSidebar() {\n return <nav />;\n}\n\nexport default observer(SharedSidebar);\n",
'src/components/Sidebar/index.ts': "import Sidebar from './App';\n\nexport default Sidebar;\n",
'src/scenes/Home.tsx': "import Sidebar from '../components/Sidebar';\n\nexport function Home() {\n return <main><Sidebar /></main>;\n}\n",
'src/scenes/Settings.tsx':
"import Sidebar from '../components/Sidebar';\n\nexport function Settings() {\n return <main><Sidebar /></main>;\n}\n",
};
let root: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (root) fs.rmSync(root, { recursive: true, force: true });
root = undefined;
});
function write(dir: string, files: Files): void {
for (const [file, text] of Object.entries(files)) {
const full = path.join(dir, file);
if (text === null) {
fs.rmSync(full, { force: true });
continue;
}
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, text);
}
}
/** Every node and edge by natural key, edge metadata included, so two indexes of the same files compare directly. */
function graphOf(graph: CodeGraph): string[] {
const keys = new Map(
graph.getFiles()
.flatMap((file) => graph.getNodesInFile(file.path))
.map((n) => [n.id, `${n.kind} ${n.filePath}:${n.qualifiedName}:${n.startLine}`] as const)
);
const edges = graph.getOutgoingEdgesFrom([...keys.keys()]).map((e) =>
`${keys.get(e.source)} -${e.kind}-> ${keys.get(e.target) ?? e.target} ${JSON.stringify(e.metadata ?? null)} @${e.line ?? ''}:${e.column ?? ''}`);
return [...[...keys.values()].map((k) => `node ${k}`), ...edges].sort();
}
/** `<parent> -> <child file>::<child>` for every JSX render edge. */
function renders(graph: CodeGraph): string[] {
const out: string[] = [];
for (const file of graph.getFiles()) {
for (const node of graph.getNodesInFile(file.path)) {
for (const e of graph.getOutgoingEdges(node.id)) {
if (e.metadata?.synthesizedBy !== 'jsx-render') continue;
const child = graph.getNode(e.target);
out.push(`${node.name} -> ${child?.filePath}::${child?.name}`);
}
}
}
return out.sort();
}
/** The graph of a fresh index of `files`, in a folder of its own. */
async function freshGraph(files: Files): Promise<string[]> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jsx-forward-fresh-'));
try {
write(dir, Object.fromEntries(Object.entries(files).filter(([, text]) => text !== null)));
const fresh = await CodeGraph.init(dir, { index: true });
try {
return graphOf(fresh);
} finally {
fresh.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
/**
* Index `initial`, then write each step and sync after it — with `scoped`, a
* sync of only the step's files, as the file watcher runs it. Returns the
* final files.
*/
async function indexThenSync(opts: { scoped: boolean }, initial: Files, ...steps: Files[]): Promise<Files> {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jsx-forward-'));
write(root, initial);
cg = await CodeGraph.init(root, { index: true });
const files = { ...initial };
for (const step of steps) {
write(root, step);
Object.assign(files, step);
await cg.sync(opts.scoped ? { paths: Object.keys(step) } : {});
}
return files;
}
describe('sync redraws a tag imported through a module that forwards it', () => {
it('renders the forwarded component on a fresh index', async () => {
await indexThenSync({ scoped: false }, BASE);
expect(renders(cg!)).toEqual([
'Home -> src/components/Sidebar/App.tsx::AppSidebar',
'Settings -> src/components/Sidebar/App.tsx::AppSidebar',
]);
}, 60_000);
it.each([false, true])('when only the index changes which module it forwards (scoped=%s)', async (scoped) => {
const files = await indexThenSync(
{ scoped },
BASE,
// Neither page changes, and the index has no markup of its own.
{ 'src/components/Sidebar/index.ts': "import Sidebar from './Shared';\n\nexport default Sidebar;\n" }
);
expect(renders(cg!)).toEqual([
'Home -> src/components/Sidebar/Shared.tsx::SharedSidebar',
'Settings -> src/components/Sidebar/Shared.tsx::SharedSidebar',
]);
expect(graphOf(cg!)).toEqual(await freshGraph(files));
}, 60_000);
it('when the index starts holding the other module’s component in a value', async () => {
const files = await indexThenSync(
{ scoped: true },
BASE,
// outline's `Avatar/index.ts` shape: `const Avatar = AvatarWithHoverCard; export { Avatar }`.
{ 'src/components/Sidebar/index.ts': "import Shared from './Shared';\n\nconst Sidebar = Shared;\n\nexport default Sidebar;\n" }
);
expect(renders(cg!)).toEqual([
'Home -> src/components/Sidebar/Shared.tsx::SharedSidebar',
'Settings -> src/components/Sidebar/Shared.tsx::SharedSidebar',
]);
expect(graphOf(cg!)).toEqual(await freshGraph(files));
}, 60_000);
it('when the module behind the index wraps another component', async () => {
const files = await indexThenSync(
{ scoped: false },
BASE,
{
'src/components/Sidebar/App.tsx':
"import { observer } from 'mobx-react';\n\nfunction AppSidebar() {\n return <nav />;\n}\n\nfunction CompactSidebar() {\n return <nav />;\n}\n\nexport default observer(CompactSidebar);\n",
}
);
expect(renders(cg!)).toEqual([
'Home -> src/components/Sidebar/App.tsx::CompactSidebar',
'Settings -> src/components/Sidebar/App.tsx::CompactSidebar',
]);
expect(graphOf(cg!)).toEqual(await freshGraph(files));
}, 60_000);
});