mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
fix(react): a component a file declares itself as a value is what its JSX renders (#2420)
A PascalCase name that a .tsx/.jsx file declares as a module-level value is
that declaration wherever the file uses it: a JSX tag, a route's element, a
layout or a TSX type. Its initializer decides what it renders, in order:
- the component of the module its lazy loader loads, or the export the
loader picks, in either branch of a conditional;
- the function it wraps (`observer(function X() {…})`,
`forwardRef(Inner) as {…}`);
- else the declaration itself.
Before, the lookup by kind skipped the value and took a lone same-named
component anywhere, often in another app of the repository.
- Pattern 1 asks declaredComponent about every PascalCase reference, not
only route references. layoutComponent and the jsx-render synthesizer's
jsxChild ask it too.
- valueLoader reads a value's loader with loaderValue (#2480).
- defaultExportedName reads `export default observer(Login)`.
- lazyValueModules records the modules a file's lazy values load as
synthesis inputs, so a sync that changes one of them redraws the tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -150,6 +150,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. A module loaded with `require('./x').default` is read the same way. Re-index JavaScript and TypeScript projects after upgrading.
|
||||
- In React and Vue, a tag for a component the file imports from a package, like `<Button>` from antd, `<Typography>` from `@mui/material/Typography`, `<Link>` from react-router-dom or `<Card>` from ant-design-vue, no longer links to a component, function or class in your repository that only shares its name. Before, the tag linked to whatever the repository had by that name, so `codegraph_explore`, callers and impact followed renders that never happen: in SigNoz, antd's `<Button>` tags showed up as callers of a styled link on its 404 page, and in Material UI dashboards like mantis and berry, `<Typography>` and `<Button>` tags showed up as callers of the theme's style overrides. A package here is one that a `package.json` above the file lists and that no file in the repository answers to. A tag imported through a path alias like `components/Modal`, from a workspace package of the repository, or under a name no `package.json` lists still links as before. Re-index React and Vue projects after upgrading.
|
||||
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
|
||||
- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading.
|
||||
|
||||
## [1.6.2] - 2026-10-03
|
||||
|
||||
|
||||
@@ -501,3 +501,298 @@ export function PostList({ posts }: { posts: IPost[] }) {
|
||||
expect(renders('PostList')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A tag the file declares itself as a VALUE — a lazily loaded page, a
|
||||
* wrapped component, an alias — is that declaration, whatever else in the
|
||||
* repository shares its name. A value is no component node, so the lookup by
|
||||
* kind used to skip it and take a lone same-named function anywhere: on
|
||||
* codedthemes' mantis (a vite app and a Next.js app in one repository) the
|
||||
* vite app's `RegisterPage` went to the Next.js app's page, and excalidraw's
|
||||
* Next.js example rendered the docs site's `Excalidraw`. Component resolution
|
||||
* keeps the same rule for every other reference to such a name (the last case).
|
||||
*/
|
||||
describe('JSX child: a value the file declares itself', () => {
|
||||
let dir: string;
|
||||
let cg: any;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-own-'));
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cg?.close?.();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const write = (rel: string, body: string) => {
|
||||
const p = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, body);
|
||||
};
|
||||
|
||||
async function index(): Promise<void> {
|
||||
cg = await CodeGraph.init(dir, { silent: true });
|
||||
await cg.indexAll();
|
||||
}
|
||||
|
||||
/** `kind name file` of each node a jsx-render edge out of `parent` points at. */
|
||||
const renders = (parent: string): string[] =>
|
||||
cg.db.db
|
||||
.prepare(
|
||||
`SELECT t.kind || ' ' || t.name || ' ' || t.file_path AS r FROM edges e
|
||||
JOIN nodes s ON s.id = e.source
|
||||
JOIN nodes t ON t.id = e.target
|
||||
WHERE s.name = ? AND json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
|
||||
ORDER BY r`
|
||||
)
|
||||
.all(parent)
|
||||
.map((row: any) => row.r);
|
||||
|
||||
const LOADABLE = 'export default function Loadable(Component) {\n return (props) => <Component {...props} />;\n}\n';
|
||||
|
||||
it('renders the module a lazy value loads, never another app’s same-named page', async () => {
|
||||
write('next/src/app/(auth)/register/page.jsx', 'export default function RegisterPage() {\n return <div />;\n}\n');
|
||||
write('vite/src/pages/auth/Register.jsx', 'export default function Register() {\n return <div />;\n}\n');
|
||||
write('vite/src/components/Loadable.jsx', LOADABLE);
|
||||
write(
|
||||
'vite/src/layout/AuthShell.jsx',
|
||||
`import { lazy } from 'react';
|
||||
import Loadable from '../components/Loadable';
|
||||
|
||||
const RegisterPage = Loadable(lazy(() => import('../pages/auth/Register')));
|
||||
|
||||
export function AuthShell() {
|
||||
return <main><RegisterPage /></main>;
|
||||
}
|
||||
`
|
||||
);
|
||||
await index();
|
||||
expect(renders('AuthShell')).toEqual(['function Register vite/src/pages/auth/Register.jsx']);
|
||||
});
|
||||
|
||||
it('renders the declaration itself when the module it loads is out of reach', async () => {
|
||||
write('next/src/app/(auth)/login/page.jsx', 'export default function LoginPage() {\n return <div />;\n}\n');
|
||||
write('vite/src/components/Loadable.jsx', LOADABLE);
|
||||
write(
|
||||
'vite/src/layout/AuthShell.jsx',
|
||||
`import { lazy } from 'react';
|
||||
import Loadable from '../components/Loadable';
|
||||
|
||||
// An import only the app's own build resolves (\`baseUrl\`).
|
||||
const LoginPage = Loadable(lazy(() => import('pages/auth/Login')));
|
||||
|
||||
export function AuthShell() {
|
||||
return <main>Sign in <LoginPage /></main>;
|
||||
}
|
||||
`
|
||||
);
|
||||
await index();
|
||||
expect(renders('AuthShell')).toEqual(['constant LoginPage vite/src/layout/AuthShell.jsx']);
|
||||
});
|
||||
|
||||
it('reads next/dynamic’s `(await import(…)).default` and a `.then` that picks a named export', async () => {
|
||||
// excalidraw's docs site has a component of the same name.
|
||||
write('dev-docs/src/theme/ReactLiveScope/index.js', 'const Excalidraw = React.forwardRef((props, ref) => <div ref={ref} />);\nexport default { Excalidraw };\n');
|
||||
write('examples/with-nextjs/src/excalidrawWrapper.tsx', 'const ExcalidrawWrapper = () => {\n return <div />;\n};\nexport default ExcalidrawWrapper;\n');
|
||||
write(
|
||||
'examples/with-nextjs/src/pages/excalidraw-in-pages.tsx',
|
||||
`import dynamic from 'next/dynamic';
|
||||
|
||||
const Excalidraw = dynamic(
|
||||
async () => (await import('../excalidrawWrapper')).default,
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
return <Excalidraw />;
|
||||
}
|
||||
`
|
||||
);
|
||||
write('a-decoy/line-chart.tsx', 'export function LineChart() {\n return <svg />;\n}\n');
|
||||
write('charts/line-chart.tsx', 'export function LineChart() {\n return <svg />;\n}\n');
|
||||
write('charts/index.ts', "export default function Fallback() {\n return null;\n}\nexport * from './line-chart';\n");
|
||||
write(
|
||||
'dashboard.tsx',
|
||||
`import { lazy } from 'react';
|
||||
const LineChart = lazy(() => import('./charts').then((m) => ({ default: m.LineChart })));
|
||||
export function Dashboard() {
|
||||
return <LineChart />;
|
||||
}
|
||||
`
|
||||
);
|
||||
await index();
|
||||
expect(renders('Page')).toEqual(['function ExcalidrawWrapper examples/with-nextjs/src/excalidrawWrapper.tsx']);
|
||||
expect(renders('Dashboard')).toEqual(['function LineChart charts/line-chart.tsx']);
|
||||
});
|
||||
|
||||
it('follows a module that forwards its default export to the component it wraps', async () => {
|
||||
// outline: `scenes/Login/index.ts` forwards `Login.tsx`'s `observer(Login)`.
|
||||
write('a-decoy/Login.tsx', 'export function Login() {\n return <form />;\n}\n');
|
||||
write(
|
||||
'app/scenes/Login/Login.tsx',
|
||||
"import { observer } from 'mobx-react';\nfunction Login() {\n return <form />;\n}\n\nexport default observer(Login);\n"
|
||||
);
|
||||
write('app/scenes/Login/index.ts', 'import Login from "./Login";\n\nexport default Login;\n');
|
||||
write(
|
||||
'app/scenes/Shared/index.tsx',
|
||||
`import { lazy } from 'react';
|
||||
const Login = lazy(() => import('../Login'));
|
||||
export function SharedScene() {
|
||||
return <Login />;
|
||||
}
|
||||
`
|
||||
);
|
||||
await index();
|
||||
expect(renders('SharedScene')).toEqual(['function Login app/scenes/Login/Login.tsx']);
|
||||
});
|
||||
|
||||
it('renders the function a value wraps, and an alias value itself', async () => {
|
||||
write('a-decoy/portal.tsx', 'export function DialogPortal() {\n return <div />;\n}\n');
|
||||
write(
|
||||
'app/settings.tsx',
|
||||
`import { observer } from 'mobx-react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const Application = observer(function Application() {
|
||||
return <div />;
|
||||
});
|
||||
|
||||
function TableViewInner() {
|
||||
return <table />;
|
||||
}
|
||||
|
||||
const TableView = observer(TableViewInner) as typeof TableViewInner;
|
||||
|
||||
export function Settings() {
|
||||
return <DialogPortal><Application /><TableView /></DialogPortal>;
|
||||
}
|
||||
`
|
||||
);
|
||||
write('a-decoy/table-view.tsx', 'export class TableView {}\n');
|
||||
await index();
|
||||
expect(renders('Settings')).toEqual([
|
||||
'constant DialogPortal app/settings.tsx',
|
||||
'function Application app/settings.tsx',
|
||||
'function TableViewInner app/settings.tsx',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders the branch of a conditional that loads lazily, and a wrapper typed past its call or given options', async () => {
|
||||
// SigNoz loads its devtools in development only and memoizes a table body
|
||||
// with a comparator; mastodon types its `forwardRef` with an object type
|
||||
// that keeps the generics.
|
||||
write('a-decoy/AuthZDevModal.tsx', 'export function AuthZDevModal() {\n return <div />;\n}\n');
|
||||
write('a-decoy/Combobox.tsx', 'export function Combobox() {\n return <div />;\n}\n');
|
||||
write('a-decoy/MemoizedRows.tsx', 'export function MemoizedRows() {\n return <div />;\n}\n');
|
||||
write('src/lib/devtools/AuthZDevModal.tsx', 'export function AuthZDevModal() {\n return <dialog />;\n}\n');
|
||||
write(
|
||||
'src/palette.tsx',
|
||||
`import React, { forwardRef } from 'react';
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV === 'development';
|
||||
|
||||
const AuthZDevModal = IS_DEV
|
||||
? React.lazy(() =>
|
||||
import('./lib/devtools/AuthZDevModal').then((m) => ({
|
||||
default: m.AuthZDevModal,
|
||||
})),
|
||||
)
|
||||
: null;
|
||||
|
||||
const ComboboxWithRef = <Item extends { id: string }>(
|
||||
props: { items: Item[] },
|
||||
ref: React.ForwardedRef<HTMLInputElement>,
|
||||
) => {
|
||||
return <input ref={ref} />;
|
||||
};
|
||||
|
||||
export const Combobox = forwardRef(ComboboxWithRef) as {
|
||||
<Item extends { id: string }>(props: { items: Item[] }): ReturnType<typeof ComboboxWithRef>;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
function Rows({ rows }: { rows: string[] }) {
|
||||
return <ul>{rows.map((row) => <li key={row}>{row}</li>)}</ul>;
|
||||
}
|
||||
|
||||
const MemoizedRows = React.memo(
|
||||
Rows,
|
||||
(prev, next) => prev.rows === next.rows,
|
||||
) as typeof Rows;
|
||||
|
||||
export function Palette() {
|
||||
return <div>{AuthZDevModal && <AuthZDevModal />}<Combobox items={[]} /><MemoizedRows rows={[]} /></div>;
|
||||
}
|
||||
`
|
||||
);
|
||||
await index();
|
||||
expect(renders('Palette')).toEqual([
|
||||
'function AuthZDevModal src/lib/devtools/AuthZDevModal.tsx',
|
||||
'function ComboboxWithRef src/palette.tsx',
|
||||
'function Rows src/palette.tsx',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders nothing for a type argument naming the value, nor for an import inside a function it wraps', async () => {
|
||||
write('a-decoy/schema.tsx', 'export function Schema() {\n return <div />;\n}\n');
|
||||
write('heavy.tsx', 'export default function Heavy() {\n return <div />;\n}\n');
|
||||
write(
|
||||
'form.tsx',
|
||||
`import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { withFallback } from './fallback';
|
||||
|
||||
const Schema = z.object({ name: z.string() });
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
const Dialog = withFallback('Dialog', (props) => {
|
||||
const load = () => import('./heavy');
|
||||
return <div onClick={load} />;
|
||||
});
|
||||
|
||||
export function Form() {
|
||||
const form = useForm<Schema>();
|
||||
return <form><Dialog /></form>;
|
||||
}
|
||||
`
|
||||
);
|
||||
write('fallback.tsx', 'export function withFallback(name, render) {\n return render;\n}\n');
|
||||
await index();
|
||||
expect(renders('Form')).toEqual(['constant Dialog form.tsx']);
|
||||
});
|
||||
|
||||
it('binds a TSX type named like a value the file declares to that file, never another app’s component', async () => {
|
||||
// Component resolution saw the name as a component's and took the only
|
||||
// one in the repository, in another app.
|
||||
write('admin/src/components/User.tsx', 'export function User() {\n return <div />;\n}\n');
|
||||
write(
|
||||
'web/src/forms/profile-form.tsx',
|
||||
`import { z } from 'zod';
|
||||
|
||||
const User = z.object({ name: z.string() });
|
||||
type User = z.infer<typeof User>;
|
||||
|
||||
export function ProfileForm({ user }: { user: User }) {
|
||||
return <form>{user.name}</form>;
|
||||
}
|
||||
`
|
||||
);
|
||||
await index();
|
||||
const referenced = cg.db.db
|
||||
.prepare(
|
||||
`SELECT DISTINCT t.file_path AS f FROM edges e
|
||||
JOIN nodes s ON s.id = e.source
|
||||
JOIN nodes t ON t.id = e.target
|
||||
WHERE s.name = 'ProfileForm' AND e.kind = 'references'`
|
||||
)
|
||||
.all()
|
||||
.map((row: any) => row.f);
|
||||
expect(referenced).toEqual(['web/src/forms/profile-form.tsx']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1755,3 +1755,54 @@ export function About() {
|
||||
expect({ bindings: routeBindings(cg), navigations: navigations(cg) }).toEqual(await fresh(false));
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('react-router: a layout the route file loads lazily', () => {
|
||||
let root: string;
|
||||
let cg: CodeGraph;
|
||||
beforeAll(async () => {
|
||||
({ root, cg } = await indexProject({
|
||||
'package.json': JSON.stringify({ name: 'mantis', private: true }),
|
||||
'vite/package.json': JSON.stringify({ name: 'vite-app', dependencies: { react: '^18', 'react-router-dom': '^7' } }),
|
||||
'vite/src/routes/index.jsx': `import { createBrowserRouter } from 'react-router-dom';
|
||||
import MainRoutes from './MainRoutes';
|
||||
|
||||
const router = createBrowserRouter([MainRoutes]);
|
||||
|
||||
export default router;
|
||||
`,
|
||||
'vite/src/routes/MainRoutes.jsx': `import { lazy } from 'react';
|
||||
import Loadable from '../components/Loadable';
|
||||
|
||||
const MainLayout = Loadable(lazy(() => import('../layout/MainLayout')));
|
||||
const Dashboard = Loadable(lazy(() => import('../views/Dashboard')));
|
||||
|
||||
const MainRoutes = {
|
||||
path: '/',
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
element: <Dashboard />
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default MainRoutes;
|
||||
`,
|
||||
'vite/src/layout/MainLayout/index.jsx': 'export default function Layout() {\n return <main />;\n}\n',
|
||||
'vite/src/views/Dashboard.jsx': 'export default function Dashboard() {\n return <div />;\n}\n',
|
||||
'vite/src/components/Loadable.jsx': 'export default function Loadable(Component) {\n return (props) => <Component {...props} />;\n}\n',
|
||||
// The repository's other app, with a layout of the same name.
|
||||
'next/package.json': JSON.stringify({ name: 'next-app', dependencies: { next: '^15', react: '^18' } }),
|
||||
'next/src/layout/MainLayout.jsx': 'export default function MainLayout({ children }) {\n return <main>{children}</main>;\n}\n',
|
||||
}));
|
||||
});
|
||||
afterAll(() => {
|
||||
cg?.close();
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('binds the layout to the module the route file loads, never to a same-named layout of another app', () => {
|
||||
expect(routeBindings(cg)).toEqual(['/ -> Layout', '/dashboard -> Dashboard', '/dashboard ~> Layout']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,7 +168,8 @@ describe('sync redraws a route that renders a same-file lazy value when its modu
|
||||
// module it loads leads to it.
|
||||
'/docs': ['src/pages/Docs.tsx::DocsPage'],
|
||||
});
|
||||
expect(renders(cg!)).toEqual(['App -> src/pages/Team.tsx::Team']);
|
||||
// A tag naming a lazy value renders the page that value loads, named like it or not.
|
||||
expect(renders(cg!)).toEqual(['App -> src/pages/Docs.tsx::DocsPage', 'App -> src/pages/Team.tsx::Team']);
|
||||
expect(cg!.getPendingReferenceCount()).toBe(0);
|
||||
expect(graphOf(cg!)).toEqual(await freshGraph(files));
|
||||
}, 60_000);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -40,6 +40,7 @@ import { crossTierEdges, hasCrossTierPattern, hasTestRequestPattern, testRequest
|
||||
import { enclosingFn, makeLineAt } from './synth-utils';
|
||||
import { resolveImportPath } from './import-resolver';
|
||||
import { crossesCodeBoundary, jsCodeBindsName } from './name-matcher';
|
||||
import { declaredComponent, lazyValueModules } from './frameworks/react';
|
||||
|
||||
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
|
||||
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
|
||||
@@ -1772,13 +1773,24 @@ function importedFromPackage(ctx: ResolutionContext, name: string, file: string)
|
||||
* was, a styled link in a 404 page's styles. Then the file the name is
|
||||
* imported from. Then the language, which only decides a tie: a `.tsx` tag
|
||||
* naming both a TS component and a same-named Swift class means the TS one.
|
||||
*
|
||||
* A value the file declares under the name comes first of all: `const Chart =
|
||||
* lazy(() => import('./Chart'))` renders the component that module exports,
|
||||
* `observer(function Settings() {…})` the function it wraps, and anything
|
||||
* else the declaration itself (`declaredComponent`). Kind alone left it out —
|
||||
* a value is no component node — so a lone function of that name anywhere
|
||||
* won: mantis's vite app rendered the `RegisterPage` of the repository's
|
||||
* Next.js app.
|
||||
*/
|
||||
function jsxChild(
|
||||
ctx: ResolutionContext,
|
||||
name: string,
|
||||
file: string,
|
||||
importsOf: () => Map<string, string>
|
||||
importsOf: () => Map<string, string>,
|
||||
ownValueOf: (name: string) => ReturnType<typeof declaredComponent>
|
||||
): Node | undefined {
|
||||
const own = ownValueOf(name);
|
||||
if (own) return own.component ?? own.declaration;
|
||||
const candidates = ctx.getNodesByName(name).filter((n) => JSX_CHILD_KINDS.has(n.kind));
|
||||
const local = candidates.find((n) => n.filePath === file);
|
||||
if (local) return local;
|
||||
@@ -1856,7 +1868,8 @@ function declaredInside(ctx: ResolutionContext, name: string, parent: Node, chil
|
||||
* a tag, not only in a type argument or parameter list (`useState<User>()`,
|
||||
* `<Entry extends BaseEntity>(…) =>`; outline's `<PaginatedList<Document>`
|
||||
* read its `Document` model class as a child), and the name must resolve to a
|
||||
* component/function/class node. A name the parent binds itself by its first
|
||||
* component/function/class node, or to a value the file declares itself (see
|
||||
* `jsxChild`). A name the parent binds itself by its first
|
||||
* tag — a parameter, or a `const` like outline's `const Content = variant ===
|
||||
* "dropdown" ? DropdownMenu.SubContent : ContextMenu.SubContent` — is that
|
||||
* local, and renders only a component declared inside the parent
|
||||
@@ -1884,6 +1897,12 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
|
||||
let imports: Map<string, string> | null = null;
|
||||
const importsOf = () =>
|
||||
(imports ??= importedFrom(ctx, file, parents[0]!.language));
|
||||
// Once per file and name: the value the file declares under it, if any.
|
||||
const ownValues = new Map<string, ReturnType<typeof declaredComponent>>();
|
||||
const ownValueOf = (name: string) => {
|
||||
if (!ownValues.has(name)) ownValues.set(name, declaredComponent(name, file, ctx));
|
||||
return ownValues.get(name);
|
||||
};
|
||||
// A function renders the tags its own lines hold, so functions that span
|
||||
// the same lines share them: every function of a minified bundle spans
|
||||
// its one line, and reading that line again per function took 9 to 25 s
|
||||
@@ -1920,7 +1939,7 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
|
||||
for (const [name, line] of names.tags) {
|
||||
if (added >= MAX_JSX_CHILDREN) break;
|
||||
if (!line) continue;
|
||||
let child = jsxChild(ctx, name, file, importsOf);
|
||||
let child = jsxChild(ctx, name, file, importsOf, ownValueOf);
|
||||
// A name the parent binds itself by its first tag is that local.
|
||||
if (child && names.bare.has(name) && jsCodeBindsName(name, parent, file, line, ctx)) {
|
||||
child = declaredInside(ctx, name, parent, child);
|
||||
@@ -4660,15 +4679,20 @@ export async function synthesizeCallbackEdges(
|
||||
// Files whose text could feed a pass (inputs that produce NO edges included,
|
||||
// e.g. an over-cap channel: deleting one may make the full pass viable). It
|
||||
// depends only on the files, so with a pool the main thread reads them while
|
||||
// the workers run the passes instead of after.
|
||||
// the workers run the passes instead of after. A module a file's own lazy
|
||||
// value loads feeds the JSX that names the value, even with no markup of its
|
||||
// own: a page given its default export, or a barrel rewired, redraws it.
|
||||
const collectInputs = async (): Promise<string[]> => {
|
||||
const found: string[] = [];
|
||||
const found = new Set<string>();
|
||||
for (const file of ctx.getAllFiles()) {
|
||||
const content = ctx.readFile(file);
|
||||
if (content !== null && hasSynthesisPattern(file, content)) found.push(file);
|
||||
if (content !== null && hasSynthesisPattern(file, content)) {
|
||||
found.add(file);
|
||||
if (/\bimport\s*\(/.test(content)) for (const read of lazyValueModules(file, ctx)) found.add(read);
|
||||
}
|
||||
await yieldToLoop();
|
||||
}
|
||||
return found;
|
||||
return [...found];
|
||||
};
|
||||
let inputs: string[] | null = null;
|
||||
|
||||
|
||||
@@ -84,15 +84,15 @@ export const reactResolver: FrameworkResolver = {
|
||||
isPascalCase(ref.referenceName) &&
|
||||
!isBuiltInType(ref.referenceName)
|
||||
) {
|
||||
// What a route renders, when its own file declares it: the module a
|
||||
// `const RegisterPage = Loadable(lazy(() => import('pages/auth/Register')))`
|
||||
// loads, or else that declaration, which name matching binds — never a
|
||||
// same-named component in another app of the repository.
|
||||
if (ref.fromNodeId.startsWith(`route:${ref.filePath}:`)) {
|
||||
const own = declaredComponent(ref.referenceName, ref.filePath, context);
|
||||
if (own !== undefined) {
|
||||
return own ? { original: ref, targetNodeId: own, confidence: 0.9, resolvedBy: 'framework' } : null;
|
||||
}
|
||||
// A name the file declares itself is that declaration, whatever refers
|
||||
// to it — a route's `element`, a page's default export, a type: the
|
||||
// module a `const RegisterPage = Loadable(lazy(() =>
|
||||
// import('pages/auth/Register')))` loads, the function a value wraps, or
|
||||
// else the declaration, which name matching binds — never a same-named
|
||||
// component in another app of the repository.
|
||||
const own = declaredComponent(ref.referenceName, ref.filePath, context);
|
||||
if (own) {
|
||||
return own.component ? { original: ref, targetNodeId: own.component.id, confidence: 0.9, resolvedBy: 'framework' } : null;
|
||||
}
|
||||
const result = resolveComponent(ref.referenceName, ref.filePath, context);
|
||||
if (result) {
|
||||
@@ -212,9 +212,9 @@ export const reactResolver: FrameworkResolver = {
|
||||
/**
|
||||
* The modules a route's answer is read from: a lazily loaded page or layout
|
||||
* (`lazy-import:` references, and `import:…#…` for the export a loader
|
||||
* picks), or the one a same-file lazy value the route renders loads — the
|
||||
* references Pattern 1 asks `declaredComponent` about — with the modules a
|
||||
* barrel there forwards the lookup to.
|
||||
* picks), or the one a same-file lazy value the route renders, or renders
|
||||
* inside, loads (`declaredComponent`) — with the modules a barrel there
|
||||
* forwards the lookup to.
|
||||
*/
|
||||
lazyModules(ref: UnresolvedRef, context: ResolutionContext): readonly string[] {
|
||||
const name = ref.referenceName;
|
||||
@@ -227,8 +227,17 @@ export const reactResolver: FrameworkResolver = {
|
||||
let spec: string | null | undefined;
|
||||
if (name.startsWith(LAZY_ROUTE_PREFIX)) spec = name.slice(LAZY_ROUTE_PREFIX.length);
|
||||
else if (name.startsWith(LAYOUT_PREFIX + LAZY_ROUTE_PREFIX)) spec = name.slice(LAYOUT_PREFIX.length + LAZY_ROUTE_PREFIX.length);
|
||||
else if ((ref.language === 'tsx' || ref.language === 'jsx') && isPascalCase(name) && !isBuiltInType(name) &&
|
||||
ref.fromNodeId.startsWith(`route:${ref.filePath}:`)) spec = declaredLoader(name, ref.filePath, context);
|
||||
else if (isReactRouteRef(ref)) {
|
||||
// A page or layout the route file declares as a value it loads lazily.
|
||||
const own = name.startsWith(LAYOUT_PREFIX) ? name.slice(LAYOUT_PREFIX.length) : name;
|
||||
const loaded = isPascalCase(own) && !isBuiltInType(own) ? declaredLoader(own, ref.filePath, context) : undefined;
|
||||
if (loaded && loaded.member !== 'default') {
|
||||
const reads = new Set<string>();
|
||||
pickedExport(loaded.spec, loaded.member, ref.filePath, context, reads);
|
||||
return [...reads];
|
||||
}
|
||||
spec = loaded?.spec;
|
||||
}
|
||||
return spec ? lazyRouteFiles(spec, ref.filePath, context) : [];
|
||||
},
|
||||
};
|
||||
@@ -435,9 +444,15 @@ function readObjectPath(text: string, at: number, keys: string[]): string | null
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The file a route's lazily loaded module is, from the file that loads it. */
|
||||
/**
|
||||
* The file a lazily loaded module is, from the file that loads it: by the
|
||||
* extensions that file's own imports resolve with (`./MainLayout` is
|
||||
* `MainLayout/index.jsx` from a `.jsx` file), else by TypeScript's.
|
||||
*/
|
||||
function lazyModuleFile(spec: string, fromFile: string, context: ResolutionContext): string | null {
|
||||
return resolveImportPath(spec, fromFile, 'typescript', context);
|
||||
const language = scriptLanguage(fromFile);
|
||||
return resolveImportPath(spec, fromFile, language, context) ??
|
||||
(language === 'typescript' ? null : resolveImportPath(spec, fromFile, 'typescript', context));
|
||||
}
|
||||
|
||||
/** The component a lazy route module renders: its default export, else its `Component` export. */
|
||||
@@ -508,8 +523,10 @@ function exportedComponent(
|
||||
const code = stripCommentsForRegex(context.readFile(file) ?? '', 'typescript');
|
||||
const local = ownExport(code, name);
|
||||
if (local) {
|
||||
const declared = context.getNodesInFile(file).find((n) => n.name === local && EXPORTED_COMPONENT_KINDS.has(n.kind));
|
||||
if (declared) return declared.id;
|
||||
const named = context.getNodesInFile(file).filter((n) => n.name === local && EXPORTED_COMPONENT_KINDS.has(n.kind));
|
||||
const declared = named.find((n) => n.qualifiedName === local) ?? named[0];
|
||||
// A value is what it wraps: `export default Page` of `const Page = observer(function Page_() {…})`.
|
||||
if (declared) return (declared.kind === 'constant' || declared.kind === 'variable' ? wrappedComponent(declared, context)?.id : undefined) ?? declared.id;
|
||||
// A name the module imports and exports again — `import { Page } from
|
||||
// './page'; export { Page }`, `import Page from './Page'; export default
|
||||
// Page` — is the page that import names. `export default withAuth(Page)`
|
||||
@@ -534,13 +551,14 @@ function exportedComponent(
|
||||
}
|
||||
|
||||
/**
|
||||
* The local name a module's own `export` gives `name`: `export default Page`
|
||||
* (or `function Page`, `class Page`), `export function Component`, `export
|
||||
* const Component`, `export { Page as Component }`. Null when it has none.
|
||||
* The local name a module's own `export` gives `name`: its default export
|
||||
* (`defaultExportedName`: `export default Page`, `function Page`, `class
|
||||
* Page`, `observer(Page)`), `export function Component`, `export const
|
||||
* Component`, `export { Page as Component }`. Null when it has none.
|
||||
*/
|
||||
function ownExport(source: string, name: string): string | null {
|
||||
if (name === 'default') {
|
||||
const declared = /\bexport\s+default\s+(?:async\s+)?(?:function\s*\*?\s*|class\s+)?([A-Za-z_$][\w$]*)/.exec(source)?.[1];
|
||||
const declared = defaultExportedName(source);
|
||||
if (declared) return declared;
|
||||
} else if (new RegExp(String.raw`\bexport\s+(?:async\s+)?(?:const|let|var|function|class)\s+${name.replace(/\$/g, '\\$')}(?![\w$])`).test(source)) {
|
||||
return name;
|
||||
@@ -548,37 +566,247 @@ function ownExport(source: string, name: string): string | null {
|
||||
return extractLocalExportAliases(source).find((a) => a.exportedName === name)?.localName ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a module's default export carries: `export default Login`,
|
||||
* `export default function Login`, `export default class Login`, `export
|
||||
* default Login as FC`, or the component a wrapper call hands over —
|
||||
* `observer(Login)`, `React.memo(Login)`, `connect(mapState)(Login)`,
|
||||
* `withTranslation()(withRouter(Login))`.
|
||||
*/
|
||||
function defaultExportedName(source: string): string | null {
|
||||
const at = /\bexport\s+default\s+/.exec(source);
|
||||
if (!at) return null;
|
||||
const rest = source.slice(at.index + at[0].length);
|
||||
const named = /^(?:async\s+)?(?:function\s*\*?\s*|class\s+)([A-Za-z_$][\w$]*)/.exec(rest) ??
|
||||
/^([A-Za-z_$][\w$]*)(?:[ \t]*(?:;|\r?\n|$)|\s+(?:as|satisfies)\s)/.exec(rest);
|
||||
return named ? named[1]! : wrappedName(rest.slice(0, statementEnd(rest, 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The component a wrapper call hands over — `observer(Login)`, `React.memo(Login)`,
|
||||
* `connect(mapState)(Login)`, `withTranslation()(withRouter(Login))`: its last
|
||||
* argument list's lone argument, or a component ahead of the options after it
|
||||
* (`React.memo(TableBody, areEqual)`, `withAuthZ(Page, { … })`), past a type
|
||||
* assertion on the call (`as typeof Login`, or mastodon's
|
||||
* `forwardRef(ComboboxWithRef) as { … }`, which keeps the generics
|
||||
* `forwardRef` drops).
|
||||
*/
|
||||
function wrappedName(expression: string): string | null {
|
||||
const s = expression.trim();
|
||||
const callee = /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*\s*(?:<[^()]*>)?\s*(?=\()/.exec(s);
|
||||
if (!callee) return null;
|
||||
let end = callee[0].length;
|
||||
let args: string[] = [];
|
||||
for (let open = end; s[open] === '('; ) {
|
||||
const close = matchBracket(s, open);
|
||||
if (close < 0) return null;
|
||||
args = topLevelItems(s.slice(open + 1, close));
|
||||
end = close + 1;
|
||||
for (open = end; /\s/.test(s[open] ?? ''); ) open++;
|
||||
}
|
||||
if (!/^(?:\s+(?:as|satisfies)\s[\s\S]*)?$/.test(s.slice(end))) return null;
|
||||
const first = args[0] ?? '';
|
||||
// `connect(mapState, mapDispatch)` hands over no component yet.
|
||||
if (args.length > 1 && !/^[A-Z]/.test(first)) return null;
|
||||
return /^[A-Za-z_$][\w$]*$/.test(first) ? first : first ? wrappedName(first) : null;
|
||||
}
|
||||
|
||||
/** True when the module's default export is the binding `local` itself: `export default Page;`, `export { Page as default }`. */
|
||||
function exportsBinding(source: string, local: string): boolean {
|
||||
return defaultExportBinding(source) === local;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component `filePath` holds in a value of its own, for a route there that
|
||||
* renders it: the component of the module a lazy declaration loads (`lazy(()
|
||||
* => import('./x'))`, wrapped or not), null for a value that loads none it
|
||||
* can find — the value is then the binding, by name — and undefined when the
|
||||
* file declares no such value (a function or class component is found the
|
||||
* usual way).
|
||||
* A value `filePath` declares itself under a component's name, and what it
|
||||
* renders. JavaScript scoping makes the name, used anywhere in that file —
|
||||
* `<RegisterPage />`, a route's `element: <RegisterPage />` — this
|
||||
* declaration, never another file's same-named component: mantis's vite app
|
||||
* bound its `RegisterPage`, a `Loadable(lazy(() =>
|
||||
* import('pages/auth/Register')))`, to the page of that name in the
|
||||
* repository's Next.js app. `component` is the component of the module a lazy
|
||||
* declaration loads (`declaredLoader`), or the function the value wraps
|
||||
* (`observer(function Login() {…})`); null when there is none to find, and
|
||||
* the declaration is then the binding. Undefined when the file declares no
|
||||
* such value (a function or class component is found the usual way).
|
||||
*/
|
||||
function declaredComponent(name: string, filePath: string, context: ResolutionContext): string | null | undefined {
|
||||
const spec = declaredLoader(name, filePath, context);
|
||||
if (spec === undefined) return undefined;
|
||||
return spec ? lazyRouteComponent(spec, filePath, context) : null;
|
||||
export function declaredComponent(
|
||||
name: string,
|
||||
filePath: string,
|
||||
context: ResolutionContext,
|
||||
): { declaration: Node; component: Node | null } | undefined {
|
||||
const declaration = declaredValue(name, filePath, context);
|
||||
if (!declaration) return undefined;
|
||||
const loaded = valueLoader(declaration, context);
|
||||
if (!loaded) return { declaration, component: wrappedComponent(declaration, context) };
|
||||
const id = loaded.member === 'default'
|
||||
? lazyRouteComponent(loaded.spec, filePath, context)
|
||||
: pickedExport(loaded.spec, loaded.member, filePath, context);
|
||||
return { declaration, component: id ? context.getNodeById?.(id) ?? null : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The module a value `filePath` declares as `name` loads lazily — the
|
||||
* `'./x'` of `const X = lazy(() => import('./x'))`, wrapped or not — null
|
||||
* for a value that loads none, undefined when the file declares no such value.
|
||||
* The module a value `filePath` declares as `name` loads lazily, and the
|
||||
* export its loader picks (`valueLoader`) — null for a value that loads none,
|
||||
* undefined when the file declares no such value.
|
||||
*/
|
||||
function declaredLoader(name: string, filePath: string, context: ResolutionContext): string | null | undefined {
|
||||
const own = (context.getNodesInFileNamed?.(filePath, name) ?? context.getNodesInFile(filePath).filter((n) => n.name === name))
|
||||
.find((n) => n.kind === 'constant' || n.kind === 'variable');
|
||||
if (!own) return undefined;
|
||||
const lines = context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/) ?? [];
|
||||
const text = lines.slice(own.startLine - 1, own.endLine).join('\n');
|
||||
return /=>\s*import\s*\(\s*["']([^"']+)["']\s*\)/.exec(text)?.[1] ?? null;
|
||||
function declaredLoader(name: string, filePath: string, context: ResolutionContext): { spec: string; member: string } | null | undefined {
|
||||
const declaration = declaredValue(name, filePath, context);
|
||||
return declaration ? valueLoader(declaration, context) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The modules the values `filePath` declares load lazily, with the barrels the
|
||||
* lookups for their components pass through: what a JSX tag naming one of
|
||||
* them is drawn from (`declaredComponent`), so a sync that changes one —
|
||||
* gives a page its default export, rewires a barrel — redraws the tag.
|
||||
*/
|
||||
export function lazyValueModules(filePath: string, context: ResolutionContext): string[] {
|
||||
const reads = new Set<string>();
|
||||
for (const node of context.getNodesInFile(filePath)) {
|
||||
if ((node.kind !== 'constant' && node.kind !== 'variable') || node.qualifiedName !== node.name || !isPascalCase(node.name)) continue;
|
||||
const loaded = valueLoader(node, context);
|
||||
if (!loaded) continue;
|
||||
if (loaded.member === 'default') lazyRouteComponent(loaded.spec, filePath, context, reads);
|
||||
else pickedExport(loaded.spec, loaded.member, filePath, context, reads);
|
||||
}
|
||||
return [...reads];
|
||||
}
|
||||
|
||||
/** The module-level `const`, `let` or `var` `filePath` declares as `name`. */
|
||||
function declaredValue(name: string, filePath: string, context: ResolutionContext): Node | undefined {
|
||||
return (context.getNodesInFileNamed?.(filePath, name) ?? context.getNodesInFile(filePath).filter((n) => n.name === name))
|
||||
.find((n) => (n.kind === 'constant' || n.kind === 'variable') && n.qualifiedName === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The module a value's initializer hands a lazy loader, and the export the
|
||||
* loader resolves to — `default` unless it picks one: `lazy(() =>
|
||||
* import('./x'))`, wrapped or not (`Loadable(lazy(…))`, `loadable(…)`,
|
||||
* `dynamic(…, { ssr: false })`, `Loadable({ loader: () => import(…) })`),
|
||||
* `dynamic(async () => (await import('./x')).default)`, or a named export
|
||||
* (`.then((m) => ({ default: m.Chart }))`, `.then((m) => m.Chart)`), in either
|
||||
* branch of a conditional (`IS_DEV ? React.lazy(…) : null`). The loader is
|
||||
* read by `loaderValue`, as a data router's is; one it can't read loads the
|
||||
* module its expression's first import names. A function the value wraps is
|
||||
* no loader (`withFallback('Dialog', (props) => { … import('mermaid') … })`):
|
||||
* what it returns renders, and what it imports is its own business.
|
||||
*/
|
||||
function valueLoader(declaration: Node, context: ResolutionContext): { spec: string; member: string } | null {
|
||||
const code = stripCommentsForRegex(declarationText(declaration, context), 'typescript');
|
||||
const init = initializerAt(code, declaration.name.length);
|
||||
const loader = init < 0 ? null : loaderOf(code.slice(init));
|
||||
if (!loader) return null;
|
||||
const page = loadedPage(loaderValue(loader, new Map()));
|
||||
if (page) return page;
|
||||
const fn = functionParts(loader);
|
||||
const spec = fn && !fn.block ? FIRST_IMPORT.exec(fn.body)?.[1] : undefined;
|
||||
return spec ? { spec, member: 'default' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader function an initializer hands a lazy wrapper: the first function
|
||||
* among its call's arguments, through the wrappers around it (`Loadable(lazy(()
|
||||
* => import('./x')))`) and before the options after it (`dynamic(…, { ssr:
|
||||
* false })`), or an options object's `loader` (`Loadable({ loader: () =>
|
||||
* import(…) })`). Null for an initializer that is no such call.
|
||||
*/
|
||||
function loaderOf(text: string): string | null {
|
||||
const s = text.trim();
|
||||
const call = /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*\s*(?:<[^()]*>)?\s*\(/.exec(s);
|
||||
if (!call) return conditionalLoader(s);
|
||||
const open = call[0].length - 1;
|
||||
const close = matchBracket(s, open);
|
||||
if (close < 0) return null;
|
||||
for (const arg of topLevelItems(s.slice(open + 1, close))) {
|
||||
const item = arg.trim();
|
||||
if (!item) continue;
|
||||
if (functionParts(item)) return item;
|
||||
if (item.startsWith('{')) {
|
||||
const loader = matchBracket(item, 0) === item.length - 1 ? objectEntries(item.slice(1, -1)).props.get('loader') : undefined;
|
||||
if (loader !== undefined && functionParts(loader)) return loader;
|
||||
continue;
|
||||
}
|
||||
const inner = loaderOf(item);
|
||||
if (inner) return inner;
|
||||
}
|
||||
return conditionalLoader(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader of a conditional's one branch that has one: SigNoz's `IS_DEV ?
|
||||
* React.lazy(() => import(…)) : null`. Null when neither branch loads, or both
|
||||
* do — the value is then one or the other.
|
||||
*/
|
||||
function conditionalLoader(text: string): string | null {
|
||||
const branches = conditionalBranches(text);
|
||||
const loaders = branches ? branches.map(loaderOf).filter((l): l is string => l !== null) : [];
|
||||
return loaders.length === 1 ? loaders[0]! : null;
|
||||
}
|
||||
|
||||
/** The two branches of a conditional expression, `test ? yes : no`; null for any other expression. */
|
||||
function conditionalBranches(text: string): [string, string] | null {
|
||||
let question = -1;
|
||||
let open = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]!;
|
||||
if (ch === '"' || ch === "'" || ch === '`' || ch === '{' || ch === '(' || ch === '[') {
|
||||
i = ch === '{' || ch === '(' || ch === '[' ? matchBracket(text, i) : skipString(text, i);
|
||||
if (i < 0) return null;
|
||||
} else if (ch === '?') {
|
||||
// `??` and `?.` are no conditional.
|
||||
if (text[i + 1] === '?' || (text[i + 1] === '.' && !/\d/.test(text[i + 2] ?? ''))) i++;
|
||||
else if (open++ === 0) question = i;
|
||||
} else if (ch === ':' && open > 0 && --open === 0) {
|
||||
return [text.slice(question + 1, i), text.slice(i + 1)];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page a lazy wrapper's loader resolves to: a module's default export, an
|
||||
* export it picks, or the `default` of the object `React.lazy` wants (`{
|
||||
* default: m.Chart }`).
|
||||
*/
|
||||
function loadedPage(value: Loaded | null): { spec: string; member: string } | null {
|
||||
if (value?.kind === 'module') return { spec: value.spec, member: 'default' };
|
||||
if (value?.kind === 'export') return { spec: value.spec, member: value.name };
|
||||
if (value?.kind !== 'object') return null;
|
||||
const inner = objectEntries(value.text).props.get('default');
|
||||
return inner === undefined ? null : loadedPage(valueOf(inner, value.scope));
|
||||
}
|
||||
|
||||
/** A declarator's own source text: `B = lazy(…)` in `const A = lazy(…), B = lazy(…)`. */
|
||||
function declarationText(node: Node, context: ResolutionContext): string {
|
||||
const lines = (context.getFileLines?.(node.filePath) ?? context.readFile(node.filePath)?.split(/\r?\n/) ?? [])
|
||||
.slice(node.startLine - 1, node.endLine);
|
||||
if (lines.length === 0) return '';
|
||||
if (node.endColumn > 0) lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, node.endColumn);
|
||||
lines[0] = lines[0]!.slice(node.startColumn);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The component a value wraps, which is what it renders: the function it
|
||||
* holds (`const Application = observer(function Application() {…})`), or the
|
||||
* component of its own file a wrapper call is handed (`const TableView =
|
||||
* observer(TableViewInner)`). An inner function counts when it has the value's
|
||||
* name or ends where the value does — never a helper inside an arrow the graph
|
||||
* has no node for (`withFallback('X', () => { const load = () => …; … })`).
|
||||
*/
|
||||
function wrappedComponent(value: Node, context: ResolutionContext): Node | null {
|
||||
const nodes = context.getNodesInFile(value.filePath);
|
||||
const inner = nodes.filter((n) => COMPONENT_KINDS.has(n.kind) &&
|
||||
n.qualifiedName === `${value.qualifiedName}::${n.name}` && n.startLine >= value.startLine && n.endLine <= value.endLine);
|
||||
const held = inner.find((n) => n.name === value.name) ??
|
||||
(inner.length === 1 && inner[0]!.endLine === value.endLine ? inner[0]! : null);
|
||||
if (held || inner.length > 0) return held;
|
||||
const code = stripCommentsForRegex(declarationText(value, context), 'typescript');
|
||||
const init = initializerAt(code, value.name.length);
|
||||
const handed = init < 0 ? null : wrappedName(code.slice(init, statementEnd(code, init)));
|
||||
return handed && isPascalCase(handed)
|
||||
? nodes.find((n) => n.name === handed && n.qualifiedName === handed && COMPONENT_KINDS.has(n.kind)) ?? null
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -592,7 +820,14 @@ function layoutComponent(spec: string, ref: UnresolvedRef, context: ResolutionCo
|
||||
const picked = isReactRouteRef(ref) ? PICKED_EXPORT.exec(spec) : null;
|
||||
if (picked) return pickedExport(picked[1]!, picked[2]!, ref.filePath, context);
|
||||
const mapping = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === spec);
|
||||
if (!mapping) return resolveComponent(spec, ref.filePath, context);
|
||||
if (!mapping) {
|
||||
// A layout the route file declares itself (`const MainLayout =
|
||||
// Loadable(lazy(…))`): no name match follows a `layout:` reference, so
|
||||
// the declaration binds here when there is no component to find.
|
||||
const own = declaredComponent(spec, ref.filePath, context);
|
||||
if (own) return (own.component ?? own.declaration).id;
|
||||
return resolveComponent(spec, ref.filePath, context);
|
||||
}
|
||||
const viaImport = context.resolveImport?.({ ...ref, referenceName: spec })?.targetNodeId;
|
||||
if (viaImport) return viaImport;
|
||||
// `import { default as AppRoot } from './routes/app/root'`: read the module the
|
||||
|
||||
Reference in New Issue
Block a user