diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff4b07c9..825c1ed1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named. - In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of ``. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading. - In TypeScript React code, a styled-components or emotion component declared with a type argument, like `const Wrapper = styled.div` followed by its CSS, or `styled(Base)`, is now indexed as a component, as one written without a type argument already was. Before, it was indexed as a plain constant, so `codegraph callers` and impact found none of the code that renders it, and its tags could link to another file's component of the same name instead: in outline/outline, `NudeButton` and `HStack` had no callers, and `` tags linked to a different `Text` than the one each file imports. Re-index TypeScript React projects after upgrading. +- `codegraph sync` and the file watcher now move a React Router route to its page when the router file loads that page lazily into a value of its own, like `const Docs = lazy(() => import('./pages/Docs'))` rendered by `} />`, and the page's file is added after the router was indexed, comes back after being deleted, or gets or changes its default export in a later edit. Before, the route kept pointing at the `Docs` declaration, or at the page's old component, until the router file itself changed or the project was re-indexed, unless the page's component happened to share the declaration's name. The same now holds for a `lazy: () => import('./pages/Docs')` route whose page switches its default export to another component. Re-index React projects after upgrading to correct routes an earlier version left behind. +- `codegraph sync` and the file watcher now link a component to the JSX that renders it, like ``, when the component is added in a file with no JSX of its own, as in `export default function Team() { return null; }`. Before, the link waited for an unrelated change to a file with JSX, or for a re-index, so the new component looked unused. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/sync-lazy-route-value.test.ts b/__tests__/sync-lazy-route-value.test.ts new file mode 100644 index 000000000..1bd658cbc --- /dev/null +++ b/__tests__/sync-lazy-route-value.test.ts @@ -0,0 +1,328 @@ +/** + * A sync redraws what a route renders when the module behind it changes. + * + * A route that renders a value its own file declares — + * `const Docs = lazy(() => import('./pages/Docs'))` and `} />` — binds to the component that module exports, and to + * the declaration while the module is missing or exports none (#2400). The + * reference resolved either way, so nothing parked it for the failed-ref + * retry, and the rebind of the names a sync adds (CG-33) only reaches it when + * the module's component is named like the declaration. So a module added, + * given its default export, or switched to another component after the router + * was indexed left the route on its old answer until the router file changed. + * + * The other half is the JSX a component renders: `` links to the + * `Team` a sync adds only if the sync redraws the synthesized edges, and the + * file a component is added in decided that alone, by its own markup. A + * component without any (`return null`, a wrapper) passed none of those + * checks. + * + * 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, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { ReferenceResolver } from '../src/resolution'; + +type Files = Record; + +const pkg = JSON.stringify({ name: 'app', dependencies: { react: '^18', 'react-router-dom': '^6' } }); +const page = (name: string, exported = 'export default') => `${exported} function ${name}() { return null; }\n`; + +/** The router of the report: two pages it loads lazily into values of its own. */ +const APP = `import { lazy } from 'react'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +const Team = lazy(() => import('./pages/Team')); +const Docs = lazy(() => import('./pages/Docs')); +export function App() { + return ( + + + } /> + } /> + + + ); +} +`; + +let root: string | undefined; +let cg: CodeGraph | undefined; + +afterEach(() => { + vi.restoreAllMocks(); + 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(); +} + +/** What each route renders: `::`, a layout marked. */ +function routeLinks(graph: CodeGraph): Record { + const links: Record = {}; + for (const route of graph.getNodesByKind('route')) { + links[route.name] = [ + ...(links[route.name] ?? []), + ...graph.getOutgoingEdges(route.id) + .filter((e) => e.kind !== 'contains') + .map((e) => { + const target = graph.getNode(e.target); + return `${e.metadata?.layout ? 'layout ' : ''}${target?.filePath}::${target?.name}`; + }), + ].sort(); + } + return links; +} + +/** ` -> ` 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 { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-lazy-route-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(initial: Files, ...steps: Files[]): Promise { + return indexThenSyncWith({ scoped: false }, initial, ...steps); +} + +async function indexThenSyncWith(opts: { scoped: boolean }, initial: Files, ...steps: Files[]): Promise { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-lazy-route-')); + 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 route that renders a same-file lazy value when its module changes', () => { + it.each([false, true])('when the modules are added after the router (scoped=%s)', async (scoped) => { + const files = await indexThenSyncWith( + { scoped }, + { 'package.json': pkg, 'src/App.tsx': APP }, + { 'src/pages/Team.tsx': page('Team'), 'src/pages/Docs.tsx': page('DocsPage') } + ); + expect(routeLinks(cg!)).toEqual({ + '/team': ['src/pages/Team.tsx::Team'], + // The page's component is named unlike the declaration, so only the + // module it loads leads to it. + '/docs': ['src/pages/Docs.tsx::DocsPage'], + }); + expect(renders(cg!)).toEqual(['App -> src/pages/Team.tsx::Team']); + expect(cg!.getPendingReferenceCount()).toBe(0); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('when an edit gives the module its default export', async () => { + const files = await indexThenSync( + { 'package.json': pkg, 'src/App.tsx': APP, 'src/pages/Team.tsx': page('Team'), 'src/pages/Docs.tsx': page('DocsPage', 'export') }, + // No name appears or goes: only the export changes. + { 'src/pages/Docs.tsx': `${page('DocsPage', 'export')}export default DocsPage;\n` } + ); + expect(routeLinks(cg!)['/docs']).toEqual(['src/pages/Docs.tsx::DocsPage']); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('when the module’s default export moves to another component', async () => { + const files = await indexThenSync( + { 'package.json': pkg, 'src/App.tsx': APP, 'src/pages/Team.tsx': page('Team'), 'src/pages/Docs.tsx': page('DocsPage') }, + // The old component stays, so the route's edge follows it through the re-index. + { 'src/pages/Docs.tsx': `${page('DocsPage', 'export')}${page('Documentation')}` } + ); + expect(routeLinks(cg!)['/docs']).toEqual(['src/pages/Docs.tsx::Documentation']); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('when deleted modules come back', async () => { + const pages = { 'src/pages/Team.tsx': page('Team'), 'src/pages/Docs.tsx': page('DocsPage') }; + const files = await indexThenSync( + { 'package.json': pkg, 'src/App.tsx': APP, ...pages }, + { 'src/pages/Team.tsx': null, 'src/pages/Docs.tsx': null } + ); + // While they are gone, each route is the declaration it renders. + expect(routeLinks(cg!)).toEqual({ '/team': ['src/App.tsx::Team'], '/docs': ['src/App.tsx::Docs'] }); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + + write(root!, pages); + await cg!.sync(); + expect(routeLinks(cg!)).toEqual({ '/team': ['src/pages/Team.tsx::Team'], '/docs': ['src/pages/Docs.tsx::DocsPage'] }); + expect(graphOf(cg!)).toEqual(await freshGraph({ ...files, ...pages })); + }, 60_000); + + it('when the page behind a barrel is added, then switches its default export', async () => { + const files = await indexThenSync( + { + 'package.json': pkg, + 'src/App.tsx': APP, + 'src/pages/Team.tsx': page('Team'), + // `./pages/Docs` is a barrel; the page it forwards comes later. + 'src/pages/Docs/index.ts': "export { default } from './DocsPage';\n", + }, + { 'src/pages/Docs/DocsPage.tsx': page('DocsPage') } + ); + expect(routeLinks(cg!)['/docs']).toEqual(['src/pages/Docs/DocsPage.tsx::DocsPage']); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + + // The barrel is unchanged; only the page behind it moves its default export. + const edit = { 'src/pages/Docs/DocsPage.tsx': `${page('DocsPage', 'export')}${page('Documentation')}` }; + write(root!, edit); + await cg!.sync(); + expect(routeLinks(cg!)['/docs']).toEqual(['src/pages/Docs/DocsPage.tsx::Documentation']); + expect(graphOf(cg!)).toEqual(await freshGraph({ ...files, ...edit })); + }, 60_000); + + it('in a route table another file hands the router (codedthemes’ admin templates)', async () => { + const files = await indexThenSync( + { + 'package.json': JSON.stringify({ name: 'berry', dependencies: { react: '^18', 'react-router-dom': '^7' } }), + 'src/routes/index.jsx': "import { createBrowserRouter } from 'react-router-dom';\nimport MainRoutes from './MainRoutes';\n\nconst router = createBrowserRouter([MainRoutes]);\n\nexport default router;\n", + 'src/routes/MainRoutes.jsx': `import { lazy } from 'react'; +import MainLayout from '../layout/MainLayout'; +import Loadable from '../ui-component/Loadable'; + +const DashboardDefault = Loadable(lazy(() => import('../views/dashboard/Default'))); + +const MainRoutes = { + path: '/', + element: , + children: [{ path: 'dashboard/default', element: }] +}; + +export default MainRoutes; +`, + 'src/layout/MainLayout.jsx': 'export default function MainLayout() {\n return
;\n}\n', + 'src/ui-component/Loadable.jsx': 'export default function Loadable(Component) {\n return (props) => ;\n}\n', + }, + { 'src/views/dashboard/Default.jsx': page('DashboardDefaultPage') } + ); + expect(routeLinks(cg!)['/dashboard/default']).toEqual([ + 'layout src/layout/MainLayout.jsx::MainLayout', + 'src/views/dashboard/Default.jsx::DashboardDefaultPage', + ]); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('for a data router’s lazy route whose module switches its default export', async () => { + const files = await indexThenSync( + { + 'package.json': pkg, + 'src/router.tsx': "import { createBrowserRouter } from 'react-router-dom';\nexport const router = createBrowserRouter([{ path: '/docs', lazy: () => import('./pages/Docs') }]);\n", + 'src/pages/Docs.tsx': page('DocsPage'), + }, + { 'src/pages/Docs.tsx': `${page('DocsPage', 'export')}${page('Documentation')}` } + ); + expect(routeLinks(cg!)['/docs']).toEqual(['src/pages/Docs.tsx::Documentation']); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('and re-opens nothing for a module no route loads', async () => { + await indexThenSync({ 'package.json': pkg, 'src/App.tsx': APP, 'src/pages/Team.tsx': page('Team'), 'src/pages/Docs.tsx': page('DocsPage') }); + const reopen = vi.spyOn(ReferenceResolver.prototype, 'reopenRouteModuleReaders'); + write(root!, { 'src/util.ts': 'export function slug(s: string) { return s.toLowerCase(); }\n' }); + await cg!.sync(); + expect(reopen).toHaveBeenCalledTimes(1); + expect(reopen.mock.results[0]!.value).toBe(0); + + // The router's own edit resolves its references itself. + write(root!, { 'src/App.tsx': APP.replace('/docs', '/documentation'), 'src/pages/Docs.tsx': page('Documentation') }); + await cg!.sync(); + expect(reopen.mock.results[1]!.value).toBe(0); + expect(routeLinks(cg!)['/documentation']).toEqual(['src/pages/Docs.tsx::Documentation']); + }, 60_000); +}); + +describe('sync draws the JSX edges to a component it adds', () => { + it('when another file imports and renders it', async () => { + const files = await indexThenSync( + { 'package.json': pkg, 'src/Shell.tsx': "import Team from './Team';\nexport function Shell() {\n return
;\n}\n" }, + // A component without markup of its own. + { 'src/Team.tsx': page('Team') } + ); + expect(renders(cg!)).toEqual(['Shell -> src/Team.tsx::Team']); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('when another file renders it by name alone', async () => { + const files = await indexThenSync( + { 'package.json': pkg, 'src/Shell.tsx': 'export function Shell() {\n return
;\n}\n' }, + { 'src/Badge.tsx': 'export const Badge = () => null;\n' } + ); + expect(renders(cg!)).toEqual(['Shell -> src/Badge.tsx::Badge']); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('but leaves them alone for a new name no tag can render', async () => { + await indexThenSync({ + 'package.json': pkg, + 'src/Shell.tsx': "import Team from './Team';\nexport function Shell() {\n return
;\n}\n", + 'src/Team.tsx': page('Team'), + 'src/limits.ts': 'export function clamp(n: number) { return Math.min(n, 3); }\n', + }); + const phases: string[] = []; + // A value and a lowercase function: neither is a component. + write(root!, { 'src/limits.ts': 'export const Limits = { max: 3 };\nexport function clamp(n: number) { return Math.min(n, Limits.max); }\nexport function floor(n: number) { return n; }\n' }); + await cg!.sync({ onProgress: (p) => phases.push(p.phase) }); + expect(phases).not.toContain('linking'); + expect(renders(cg!)).toEqual(['Shell -> src/Team.tsx::Team']); + }, 60_000); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 14218f306..cff08ecdc 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -330,6 +330,20 @@ Each of these cost real debugging time; they are not hypothetical. calls again, failed or not; without it the synced index keeps an answer a full index does not have. `sync-navigation-retry.test.ts` runs all seven routers through it, and asserts each router's calls end in a declared tail. +17. **A sync revisits a resolved route only by the names it carries.** A route + that renders a same-file `const Docs = lazy(() => import('./pages/Docs'))` + binds to the component that module exports, and to the declaration while + the module is missing or exports none (`declaredComponent`). The reference + resolved, so the failed-ref retry never sees it, and the rebind of the + names a sync defines (CG-33) reaches it only when the module's component + is named like the declaration: on minimal-ui-kit/material-kit-react, + `/user` stayed on its `UserPage` declaration after `src/pages/user.tsx` + (`Page`) came back. A router whose `resolve()` reads modules' content to + answer a route says which ones (`FrameworkResolver.lazyModules`), barrels it + follows included, and a sync that adds or edits one of them puts the + route's reference back for its resolution sweep. The same holds for the + JSX a component renders: a sync that adds a component redraws the + `jsx-render` edges, since a tag in a file it never touched may name it. --- diff --git a/src/db/queries.ts b/src/db/queries.ts index 783f0740a..de9b1dc8c 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -4097,6 +4097,46 @@ export class QueryBuilder { return out; } + /** + * The resolution edges out of route nodes that a change to `filePaths` can + * move: from a route in another file, to a node of one of `filePaths`, or to + * a value declared beside the route — what a route rendering + * `const Docs = lazy(() => import('./pages/Docs'))` binds to while that + * module is missing. Returned with the route's file and language, which a + * resurrection needs, in the order they were written, so references put + * back resolve in that order again. Synthesized edges carry no reference to + * resurrect and are left out. + */ + getRouteEdgesMovedBy(filePaths: readonly string[]): Array { + if (filePaths.length === 0) return []; + const files = JSON.stringify(filePaths); + const rows = this.db + .prepare( + `SELECT e.*, src.file_path AS source_file_path, src.language AS source_language + FROM nodes src + JOIN edges e ON e.source = src.id + JOIN nodes tgt ON tgt.id = e.target + WHERE src.kind = 'route' + AND e.kind != 'contains' + AND (e.provenance IS NULL OR e.provenance != 'heuristic') + AND src.file_path NOT IN (SELECT value FROM json_each(?)) + AND (tgt.file_path IN (SELECT value FROM json_each(?)) + OR (tgt.file_path = src.file_path AND tgt.kind IN ('constant', 'variable'))) + ORDER BY e.id` + ) + .all(files, files) as Array; + return rows.map((row) => ({ + ...rowToEdge(row), + edgeId: row.id, + sourceFilePath: row.source_file_path, + sourceLanguage: row.source_language, + })); + } + /** Delete edges by primary key — the rebind pass's half of a re-resolution. */ deleteEdgesByIds(edgeIds: number[]): number { if (edgeIds.length === 0) return 0; diff --git a/src/index.ts b/src/index.ts index 5bf49cca2..6fa4b8b69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,7 +52,7 @@ import { createResolver, ResolutionResult, } from './resolution'; -import { hasSynthesisPattern } from './resolution/callback-synthesizer'; +import { hasSynthesisPattern, isJsxChildCandidate } from './resolution/callback-synthesizer'; import { GraphTraverser, GraphQueryManager } from './graph'; import { findNamedCopybooks, type NamedCopybook } from './graph/cobol-copybooks'; import { ContextBuilder, createContextBuilder } from './context'; @@ -940,6 +940,19 @@ export class CodeGraph { } }); + // A component a tag in an unchanged file may render: `` links + // to the `Team` this sync adds. The gate above reads each changed file + // on its own, and a component with no markup of its own (`return null`, + // a wrapper) matches none of its patterns. Removing one needs no check: + // a component a tag rendered had synthesized edges into its file. + if (!refreshSynthesis && result.definitionDelta && result.changedFilePaths) { + const delta = new Set(result.definitionDelta); + if (this.queries.getNodesByFiles(result.changedFilePaths).some((n) => delta.has(n.name) && isJsxChildCandidate(n))) { + refreshSynthesis = true; + this.queries.setMetadata('synthesis_pending', '1'); + } + } + // Fold the store phase's WAL BEFORE the post-store reads below // (resolution reads on the main thread) — same rationale as // indexAll's fold between store and resolution. @@ -1118,6 +1131,18 @@ export class CodeGraph { } } + // The same for a route whose answer is read from a module: one that + // renders `const Docs = lazy(() => import('./pages/Docs'))` binds to + // the component the module exports, so it moves when the module is + // added or edited, though no name it carries changes. + if (filesChanged && result.changedFilePaths) { + const tModules = Date.now(); + const reopened = this.resolver.reopenRouteModuleReaders(result.changedFilePaths); + if (process.env.CODEGRAPH_SYNTH_TIMINGS) { + console.error(`[phase-timing] sync-route-modules: ${Date.now() - tModules}ms (${reopened} refs re-opened)`); + } + } + // Orphan sweep (#1187). A resolution pass that dies mid-run — the #850 // daemon liveness watchdog's SIGKILL (#1122), Ctrl-C, a crash — leaves // the refs it never reached in unresolved_refs, and the git-scoped fast diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 72704a076..237c7007e 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1702,6 +1702,18 @@ const JSX_CHILD_KINDS = new Set(['component', 'function', 'class']); */ const JSX_CHILD_LANGUAGES = [...JS_FAMILY, 'vue', 'svelte']; +/** + * Whether a JSX tag can render `node` (`jsxChild`): a script's component, + * function or class named with a tag's capital, or any component, which a + * default import names under a name of its own. A tag in a file a sync never + * touched may name one the sync adds, so adding one redraws the JSX edges. + * (A `.vue` or `.svelte` file redraws them on any change.) + */ +export function isJsxChildCandidate(node: Pick): boolean { + return JSX_CHILD_KINDS.has(node.kind) && JS_FAMILY.includes(node.language) && + (node.kind === 'component' || /^[A-Z]/.test(node.name)); +} + function languageForJsxFile(file: string): Language { if (file.endsWith('.tsx')) return 'tsx'; if (/\.[cm]?ts$/.test(file)) return 'typescript'; diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 50cc178fe..51a80549b 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -200,6 +200,22 @@ export const reactResolver: FrameworkResolver = { crossFileNodes(context: ResolutionContext) { return { kind: 'route', owns: isTableRoute, ...tableRoutes(context) }; }, + + /** + * The modules a route's answer is read from: a lazily loaded page or layout + * (`lazy-import:` references), 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. + */ + lazyModules(ref: UnresolvedRef, context: ResolutionContext): readonly string[] { + const name = ref.referenceName; + 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); + return spec ? lazyRouteFiles(spec, ref.filePath, context) : []; + }, }; const LAZY_ROUTE_PREFIX = 'lazy-import:'; @@ -397,11 +413,24 @@ 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. */ +function lazyModuleFile(spec: string, fromFile: string, context: ResolutionContext): string | null { + return resolveImportPath(spec, fromFile, 'typescript', context); +} + /** The component a lazy route module renders: its default export, else its `Component` export. */ -function lazyRouteComponent(spec: string, fromFile: string, context: ResolutionContext): string | null { - const file = resolveImportPath(spec, fromFile, 'typescript', context); +function lazyRouteComponent(spec: string, fromFile: string, context: ResolutionContext, reads?: Set): string | null { + const file = lazyModuleFile(spec, fromFile, context); if (!file) return null; - return exportedComponent(file, 'default', context) ?? exportedComponent(file, 'Component', context); + return exportedComponent(file, 'default', context, new Set(), reads) ?? + exportedComponent(file, 'Component', context, new Set(), reads); +} + +/** The files `lazyRouteComponent` reads: the module, and the ones a barrel forwards it to. */ +function lazyRouteFiles(spec: string, fromFile: string, context: ResolutionContext): string[] { + const reads = new Set(); + lazyRouteComponent(spec, fromFile, context, reads); + return [...reads]; } /** What a module can export as a component: a declaration, or a value holding one. */ @@ -418,10 +447,17 @@ const MAX_REEXPORT_HOPS = 4; * './page'`, or `export * from './page'`, which forwards every name but the * default. As in JavaScript, a name the file exports itself hides one an * `export *` would forward, and a name two `export *` modules both forward is - * exported by neither. + * exported by neither. `reads` collects every file the lookup reads. */ -function exportedComponent(file: string, name: string, context: ResolutionContext, visited: ReadonlySet = new Set()): string | null { +function exportedComponent( + file: string, + name: string, + context: ResolutionContext, + visited: ReadonlySet = new Set(), + reads?: Set +): string | null { if (visited.has(file) || visited.size > MAX_REEXPORT_HOPS) return null; + reads?.add(file); const local = ownExport(stripCommentsForRegex(context.readFile(file) ?? '', 'typescript'), name); if (local) return context.getNodesInFile(file).find((n) => n.name === local && EXPORTED_COMPONENT_KINDS.has(n.kind))?.id ?? null; const language = scriptLanguage(file); @@ -429,7 +465,7 @@ function exportedComponent(file: string, name: string, context: ResolutionContex const through = new Set(visited).add(file); const forwarded = (source: string, exported: string): string | null => { const target = resolveImportPath(source, file, language, context); - return target ? exportedComponent(target, exported, context, through) : null; + return target ? exportedComponent(target, exported, context, through, reads) : null; }; for (const re of reExports) { if (re.kind === 'named' && re.exportedName === name) return forwarded(re.source, re.originalName); @@ -469,13 +505,23 @@ function ownExport(source: string, name: string): string | null { * 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; +} + +/** + * 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. + */ +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'); - const spec = /=>\s*import\s*\(\s*["']([^"']+)["']\s*\)/.exec(text)?.[1]; - return spec ? lazyRouteComponent(spec, filePath, context) : null; + return /=>\s*import\s*\(\s*["']([^"']+)["']\s*\)/.exec(text)?.[1] ?? null; } /** diff --git a/src/resolution/index.ts b/src/resolution/index.ts index e04f9a274..ccec8c630 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -458,6 +458,42 @@ export class ReferenceResolver { return changed; } + /** + * Put back in the pending set the route references whose answer reads a + * module in `changedFilePaths` (`FrameworkResolver.lazyModules`), for a + * sync's resolution sweep. Returns the number put back. + * + * A route that renders a same-file `const Docs = lazy(() => + * import('./pages/Docs'))` binds to the declaration while the module is + * missing, and nothing revisited it once the module was added: the + * reference had resolved, so it was never parked for the failed-ref retry, + * and the rebind of the names a sync defines (CG-33) reaches it only when + * the module's component is named like the declaration. An edit that gives + * the module its default export, or moves it to another component, changes + * the answer the same way: a route's edge follows its old target through + * the module's re-index. So does a page behind a barrel the module + * forwards. References written in a changed file were resolved against the + * new files already. + */ + reopenRouteModuleReaders(changedFilePaths: readonly string[]): number { + const readers = this.frameworks.filter((f) => f.lazyModules); + if (readers.length === 0 || changedFilePaths.length === 0) return 0; + const changed = new Set(changedFilePaths); + const edgeIds: number[] = []; + const refs: UnresolvedReference[] = []; + for (const edge of this.queries.getRouteEdgesMovedBy(changedFilePaths)) { + const ref = resurrectRefFromDroppedEdge(edge); + if (!ref) continue; + const asked: UnresolvedRef = { ...ref, filePath: edge.sourceFilePath, language: edge.sourceLanguage }; + const reads = readers.some((f) => f.lazyModules!(asked, this.context).some((file) => changed.has(file))); + if (!reads) continue; + edgeIds.push(edge.edgeId); + refs.push(ref); + } + if (refs.length > 0) this.queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs); + return refs.length; + } + /** * Pre-build lightweight caches for resolution. * Node lookups are now handled by indexed SQLite queries instead of diff --git a/src/resolution/types.ts b/src/resolution/types.ts index 52d62be2a..eb17e40e8 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -371,6 +371,16 @@ export interface FrameworkResolver { * calls this describes back for its resolution sweep. */ navigation?: NavigationCalls; + /** + * The modules whose content `resolve()` reads to answer `ref`, a route + * node's reference — none for most. A React route that renders a same-file + * `const Docs = lazy(() => import('./pages/Docs'))` binds to the component + * that module exports, through any barrel that forwards it, and to the + * declaration while the module is missing or exports none: the answer + * changes with those files although no name the reference carries does, so + * a sync that adds or changes one of them resolves the reference again. + */ + lazyModules?(ref: UnresolvedRef, context: ResolutionContext): readonly string[]; } /**