From a6cddae1cde34341f5cba6c87912006a11a781fd Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 07:25:58 -0500 Subject: [PATCH 1/3] fix(sync): a route rendering a same-file lazy value follows its module, and a new component gets its JSX edges A route that renders `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 (#2400's declaredComponent). The reference had resolved, so no failed-ref retry saw it, and CG-33's rebind reached it only when the module's component shared the declaration's name: a sync that added the module, gave it its default export or moved that export left the route on its old answer. A data router's `lazy` route kept a moved default export the same way. FrameworkResolver.lazyModule names the module a route's answer reads; React implements it for lazy-import references and the same-file lazy values Pattern 1 reads. After the CG-33 rebind, sync puts back the route references whose module it added or changed (reopenRouteModuleReaders), for the orphan sweep. The jsx-render refresh read only each changed file's own content, so a new component without JSX of its own never got the edge from `` in an unchanged file. A sync whose definition delta holds a JSX-renderable node now refreshes synthesis. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + __tests__/sync-lazy-route-value.test.ts | 306 ++++++++++++++++++++++++ docs/design/framework-coverage.md | 14 ++ src/db/queries.ts | 38 +++ src/index.ts | 27 ++- src/resolution/callback-synthesizer.ts | 12 + src/resolution/frameworks/react.ts | 36 ++- src/resolution/index.ts | 38 +++ src/resolution/types.ts | 10 + 9 files changed, 479 insertions(+), 4 deletions(-) create mode 100644 __tests__/sync-lazy-route-value.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa4c1d08..f47580217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) +- `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..c188e6d7d --- /dev/null +++ b/__tests__/sync-lazy-route-value.test.ts @@ -0,0 +1,306 @@ +/** + * 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('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 c4a3765d2..a5c24c305 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -283,6 +283,20 @@ Each of these cost real debugging time; they are not hypothetical. itself mounted under another file's `` reads as if at the root, and a navigation that failed while its route was gone is not retried until a full index. +14. **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 a module's content to + answer a route says which module (`FrameworkResolver.lazyModule`), and a + sync that adds or edits that module 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 77fef9bba..6278680be 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -3999,6 +3999,44 @@ 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. 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')))` + ) + .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 862add024..7d05f996a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,7 +51,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'; @@ -932,6 +932,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. @@ -1075,6 +1088,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 ebecb41a8..10dfff8d3 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1331,6 +1331,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 72d05c91d..2e04bbbbd 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -201,6 +201,21 @@ export const reactResolver: FrameworkResolver = { crossFileNodes(context: ResolutionContext) { return { kind: 'route', owns: isTableRoute, ...tableRoutes(context) }; }, + + /** + * The module 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. + */ + lazyModule(ref: UnresolvedRef, context: ResolutionContext): string | null { + 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 ? lazyModuleFile(spec, ref.filePath, context) : null; + }, }; const LAZY_ROUTE_PREFIX = 'lazy-import:'; @@ -398,9 +413,14 @@ 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); + const file = lazyModuleFile(spec, fromFile, context); if (!file) return null; const source = context.readFile(file) ?? ''; const named = /\bexport\s+default\s+(?:async\s+)?(?:function\s*\*?\s*|class\s+)?([A-Za-z_$][\w$]*)/.exec(source)?.[1] ?? @@ -420,13 +440,23 @@ function lazyRouteComponent(spec: string, fromFile: string, context: ResolutionC * 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 f71e8c086..2628b89f4 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -444,6 +444,44 @@ export class ReferenceResolver { return changed; } + /** + * Put back in the pending set the route references whose answer reads a + * module in `changedFilePaths` (`FrameworkResolver.lazyModule`), 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. 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.lazyModule); + 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) => { + const loaded = f.lazyModule!(asked, this.context); + return loaded !== null && changed.has(loaded); + }); + 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 7eb6dead9..a235f47c5 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -317,6 +317,16 @@ export interface FrameworkResolver { * next run puts it back. */ crossFileNodes?(context: ResolutionContext): CrossFileNodes; + /** + * The module whose content `resolve()` reads to answer `ref`, a route + * node's reference, or null when it reads none. A React 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: the answer changes with the module + * although no name the reference carries does, so a sync that adds or + * changes the module resolves the reference again. + */ + lazyModule?(ref: UnresolvedRef, context: ResolutionContext): string | null; } /** From 3547b7cf6e9b14da4d351010694e4bb7d04dd164 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 07:41:35 -0500 Subject: [PATCH 2/3] fix(sync): put moved route references back in the order their edges were written Co-Authored-By: Claude Opus 5.5 --- src/db/queries.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/db/queries.ts b/src/db/queries.ts index 0ae8663f4..2a27dc22b 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -4092,8 +4092,9 @@ export class QueryBuilder { * 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. Synthesized edges carry no reference to resurrect and - * are left out. + * 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; return rows.map((row) => ({ From 0fe43c114d7a08c9af7f4f6ae9f3f59a5ea9abf6 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 11:08:26 -0500 Subject: [PATCH 3/3] fix(sync): re-open a lazy route when a page its barrel forwards changes #2436 lets a lazy route's module forward its component through barrels, so the answer reads every file that walk passes. lazyModules (was lazyModule) returns them all: a page added or edited behind an unchanged barrel moves the route on sync, as a fresh index does. Co-Authored-By: Claude Opus 5.5 --- __tests__/sync-lazy-route-value.test.ts | 22 ++++++++++++++++ docs/design/framework-coverage.md | 12 ++++----- src/resolution/frameworks/react.ts | 34 ++++++++++++++++++------- src/resolution/index.ts | 14 +++++----- src/resolution/types.ts | 16 ++++++------ 5 files changed, 67 insertions(+), 31 deletions(-) diff --git a/__tests__/sync-lazy-route-value.test.ts b/__tests__/sync-lazy-route-value.test.ts index c188e6d7d..1bd658cbc 100644 --- a/__tests__/sync-lazy-route-value.test.ts +++ b/__tests__/sync-lazy-route-value.test.ts @@ -209,6 +209,28 @@ describe('sync redraws a route that renders a same-file lazy value when its modu 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( { diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index a3cb2ffb5..d14e6b381 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -335,12 +335,12 @@ Each of these cost real debugging time; they are not hypothetical. 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 a module's content to - answer a route says which module (`FrameworkResolver.lazyModule`), and a - sync that adds or edits that module 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. + (`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/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 40441a46c..51a80549b 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -202,18 +202,19 @@ export const reactResolver: FrameworkResolver = { }, /** - * The module a route's answer is read from: a lazily loaded page or layout + * 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. + * renders loads — the references Pattern 1 asks `declaredComponent` about — + * with the modules a barrel there forwards the lookup to. */ - lazyModule(ref: UnresolvedRef, context: ResolutionContext): string | null { + 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 ? lazyModuleFile(spec, ref.filePath, context) : null; + return spec ? lazyRouteFiles(spec, ref.filePath, context) : []; }, }; @@ -418,10 +419,18 @@ function lazyModuleFile(spec: string, fromFile: string, context: ResolutionConte } /** The component a lazy route module renders: its default export, else its `Component` export. */ -function lazyRouteComponent(spec: string, fromFile: string, context: ResolutionContext): string | null { +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. */ @@ -438,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); @@ -449,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); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index f9629ed3a..943eadd09 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -460,7 +460,7 @@ export class ReferenceResolver { /** * Put back in the pending set the route references whose answer reads a - * module in `changedFilePaths` (`FrameworkResolver.lazyModule`), for 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(() => @@ -471,11 +471,12 @@ export class ReferenceResolver { * 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. References written in a changed file were resolved - * against the new files already. + * 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.lazyModule); + 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[] = []; @@ -484,10 +485,7 @@ export class ReferenceResolver { const ref = resurrectRefFromDroppedEdge(edge); if (!ref) continue; const asked: UnresolvedRef = { ...ref, filePath: edge.sourceFilePath, language: edge.sourceLanguage }; - const reads = readers.some((f) => { - const loaded = f.lazyModule!(asked, this.context); - return loaded !== null && changed.has(loaded); - }); + 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); diff --git a/src/resolution/types.ts b/src/resolution/types.ts index 9733ab9d7..ff7853a0d 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -368,15 +368,15 @@ export interface FrameworkResolver { */ navigation?: NavigationCalls; /** - * The module whose content `resolve()` reads to answer `ref`, a route - * node's reference, or null when it reads none. A React 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: the answer changes with the module - * although no name the reference carries does, so a sync that adds or - * changes the module resolves the reference again. + * 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. */ - lazyModule?(ref: UnresolvedRef, context: ResolutionContext): string | null; + lazyModules?(ref: UnresolvedRef, context: ResolutionContext): readonly string[]; } /**