Skip to content

fix(sync): a route rendering a same-file lazy value follows its module, and a new component gets its JSX edges - #2452

Open
colbymchenry wants to merge 4 commits into
mainfrom
claude/gifted-greider-fc6ebc
Open

colbymchenry wants to merge 4 commits into
mainfrom
claude/gifted-greider-fc6ebc

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

codegraph sync and the file watcher left a React Router route on a stale answer when the router file loads the page lazily into a value of its own and the page's module changes after the router was indexed. A sync that added a component with no JSX of its own also never drew the JSX edges to it. A fresh index of the same files gets both right.

Repro

package.json depends on react and react-router-dom, and src/App.tsx is:

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 (<BrowserRouter><Routes><Route path="/team" element={<Team />} /><Route path="/docs" element={<Docs />} /></Routes></BrowserRouter>); }

Index it without src/pages/, then add src/pages/Team.tsx (export default function Team() { return null; }) and src/pages/Docs.tsx (export default function DocsPage() { return null; }), and run codegraph sync. Compared with a fresh index of the same files in another folder:

  • /docs stays on constant Docs (exact-match). A fresh index binds it to function DocsPage (framework).
  • App → Team (jsx-render) is missing.

/team converges only by luck: CG-33's rebind re-opens edges by the target's name, and the page's component is named Team like the declaration.

Cause

The route. A route that renders a same-file lazy value binds, through declaredComponent (#2400), to the component that value's module exports. While the module is missing or exports none, it binds to the declaration instead. The reference resolved either way, so:

The same staleness follows an edit that gives the module its default export, or moves it to another component while the old one stays: the route's edge follows its old target through the module's re-index. That last case also holds for a data router's lazy: () => import('./x') route.

The JSX edge. jsx-render edges are redrawn only when a changed file passes the synthesis content gate (hasSynthesisPattern), or had synthesized edges touching it. A new component file with no markup of its own (return null, a wrapper, a styled component in a .ts file) passes neither, so <Team /> in an unchanged file never got its edge. This half isn't specific to lazy routes: a statically imported <Team /> behaves the same.

Fix

A new FrameworkResolver.lazyModules(ref, context) hook. It returns the modules whose content resolve() reads to answer a route's reference. React implements it for:

  • lazy-import: references, plain or behind layout:;
  • the references Pattern 1 asks declaredComponent about. Those are a route's PascalCase component in a .tsx/.jsx route file, which its own file declares as a lazy value.

The list includes every file #2436's barrel walk passes through on the way to the component: the walk (exportedComponent) now records what it reads. declaredComponent's loader reading moved into declaredLoader, and the module path into lazyModuleFile, so the hook and resolution read the same files.

ReferenceResolver.reopenRouteModuleReaders(changedFilePaths). Sync calls it right after CG-33's rebind, so the orphan sweep resolves what it puts back.

  • It loads the route edges a change can move (getRouteEdgesMovedBy): edges from a route in an unchanged file whose target is either a node of a changed file or a constant or variable beside the route. They come back in the order they were written.
  • For each one whose lazyModules include a changed file, it turns the edge back into the reference that made it, as the rebind does.
  • Edges with no refName stamp and synthesized edges are never touched.

A synthesis refresh for a new component. After the store phase, a sync whose definition delta includes a JSX-renderable node in a changed script also refreshes synthesis. That means a component, function or class with a tag's capital, or any component (isJsxChildCandidate, next to jsxChild).

  • Removing one needs no check: a component a tag rendered had synthesized edges into its file, which the existing gate already sees.
  • A body-only edit has an empty delta, and a new lowercase function or a PascalCase constant doesn't count.

Validation

main moved four times while this was validated:

Each step below names its base. The convergence checks were repeated on each base.

Tests. __tests__/sync-lazy-route-value.test.ts has 12 tests. Each sync is compared, whole graph and edge metadata included, with a fresh index in a separate folder. The cases:

  • modules added after the router, as a full sync and as the watcher's scoped sync;
  • an edit gives the module its default export;
  • the default export moves to another component;
  • deleted modules come back;
  • a page behind an unchanged barrel (fix(react-router): a lazy route links the page a barrel module forwards #2436) is added, then switches its default export;
  • codedthemes' Loadable(lazy(() => import(…))) in a route table another file hands the router;
  • a data router's lazy route whose module switches its default export;
  • a component added without markup, rendered through an import and by name alone.

Two guards: a sync of an unrelated file re-opens nothing, and a new PascalCase constant or lowercase function doesn't refresh synthesis.

Red phase.

Each half has teeth. With the re-open disabled, the 6 route-convergence cases fail. With the synthesis trigger disabled, the 2 JSX cases fail, plus the 2 route cases that also check App → Team.

On the final merge (16344af): tsc is clean, and these pass:

The scenario matrix and the CLI repro converge.

On 883504d:

Full suite. One full-suite run was made, on e0bbb66 plus this fix. This box was saturated (100% CPU, about 106 node processes from other sessions), and 721 of 6,534 tests failed:

  • 715 were timeouts.
  • The other 6 were assertions in the daemon and watcher lifecycle files.
  • Rerun serially on the final merge, mcp-daemon passes 22/22. In mcp-projectpath-lifecycle and mcp-staleness-banner, the one test each that failed then passes when run alone.

Kernel path (e0bbb66 plus this fix; the TS walker hasn't changed since). The kernel was built and loaded for TS/TSX. The new tests, a 6-scenario sync-vs-fresh matrix and the CLI repro above all converge.

Scenario matrix and CLI repro. Rerun on all three bases with wasm builds: main diverges in all 6 scenarios and the repro, and this branch converges in all of them.

Full-index A/B (init -y, scripts/dump-graph.mjs, on e0bbb66 and again on ed199e6):

  • 13 repos are byte-identical: matx, berry, mantis, material-kit-react, bulletproof-react, proshop, next-saas-starter, create-t3-turbo, outline, infrahub's frontend, luci's milo UI, folo and excalidraw.
  • material-kit-react is also identical on 883504d.
  • That is expected: the change only acts during a sync.

Late-module scenario. Every module a route reaches through a same-file lazy value was held out, the rest indexed, the files restored and codegraph sync run. Lines differing from a fresh index:

repo modules held out main this branch
minimal-ui-kit/material-kit-react 6 14 0
uilibrary/matx-react 4 0 0
outline/outline 2 0 0
  • material-kit-react goes from 14 to 0 on all three bases.
  • On main, its seven routes (/, /user, /products, /blog, /sign-in, /404, /*) stay on their UserPage-style declarations, while each module exports Page.
  • matx's and outline's components share their declaration's name, so CG-33's rebind already caught them.
  • berry and mantis load their pages through jsconfig baseUrl paths that codegraph doesn't resolve, so their routes bind to the declarations on a fresh index too.

Generic late scenario (e0bbb66). Every 7th script file was held out, then the same restore and sync, on 11 repos. On every repo the fix's drift lines are a subset of main's:

  • material-kit-react goes from 10 to 8 differing lines: /sign-in converges.
  • The other 10 drift identically in both arms. That drift is pre-existing and unrelated. On excalidraw, for example, it is 3,976 edges that resolved by name instead of through their import, plus 1,671 references still unresolved after the sync.

Cost.

  • sync-route-modules takes 0–7 ms per sync on these repos. The query walks route edges: 4 ms on outline's 1,215, 0.1 ms on vscode's index.
  • The new synthesis trigger can only fire from files that declare a renderable component but match no content pattern. That is 0–2 files on most of the React repos, 34 on folo, 61 on outline (mostly styled components in .ts files) and 14 of vscode's 15,358. Even then it fires only when such a file gains or renames a component.

Overlap

Not in this PR

  • A lazy value whose import path now resolves to a different file than before (./pages/Docs from Docs/index.tsx to a new Docs.tsx). The route's edge points into the old, unchanged file, so it isn't a candidate.

🤖 Generated with Claude Code

colbymchenry and others added 4 commits October 7, 2026 07:25
…e, 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 `<Team />` in an
unchanged file. A sync whose definition delta holds a JSX-renderable node now
refreshes synthesis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-fc6ebc

# Conflicts:
#	CHANGELOG.md
#	docs/design/framework-coverage.md
#	src/resolution/types.ts
…ere written

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant