Skip to content

fix(sync): a route links the module it lazily loads when the module appears later - #2422

Merged
colbymchenry merged 6 commits into
mainfrom
claude/strange-chatterjee-761c6e
Oct 7, 2026
Merged

colbymchenry merged 6 commits into
mainfrom
claude/strange-chatterjee-761c6e

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

codegraph sync and the file watcher never linked a route to the page or layout it lazily loads when that module was added after the router was indexed. The same happened when the module was already there and only a later edit gave it the component the route renders. The route stayed unlinked until the router file itself changed or the project was indexed again. This is the same family as #2392 (imports) and #2403 (Liquid path references) and builds on #2403, which has landed.

What was broken

Router Route Parked reference Old tail
React Router lazy: () => import('./pages/Team') lazy-import:./pages/Team (references) /pages/Team
Vue Router component: () => import('@/views/Login') import:@/views/Login#Login (calls) @/views/Login#Login
Angular loadComponent: () => import('./home/home.component') import:./home/home.component#default (references) component#default

Each router also has a layout: form for a layout route's module. #2403 already relinks the React form when the import names the extension (./pages/About.tsx). Vue's ../views/Home.vue#Home and every Angular reference never relinked, with or without one.

Cause

Sync retries a parked failed ref by its name_tail (#1240), looking the tail up among the changed files' symbol names and, since #2403, their file names. These refs name a module by its path, so the dotted tail is a path fragment or whatever follows the module's own dot. No file's keys match it. They are not imports either, so #2392's added-file path lookup, which is scoped to reference_kind = 'imports', never sees them.

Fix

  • referenceNameTail parks a route's module reference under the stem of the module's path, prefixed module:. lazy-import:./pages/Team becomes module:Team and import:./home/home.component#HomeComponent becomes module:home. It covers React's lazy-import:<path> and Vue's and Angular's import:<path>#<export>, each also behind layout:, for references and calls refs (Vue's route ref is a call).
  • moduleReferenceKeys(file) is importPathKeys behind module:: the file's name, its stem and its folder, so pages/Team/index.tsx satisfies ./pages/Team.
    • Sync adds these keys for every changed file to the existing symbol retry. That retry already uses idx_unresolved_failed_tail, and only module refs carry these tails, so there is no new index.
    • The keys cover changed files, not only added ones, because a route renders the component its module exports. An edit that adds the export default, the Vue component or the Angular class is what the route waited for.
  • Schema v14 rewrites the tails of module refs parked before this version.
    • Its SELECT uses +status / +reference_kind. With plain terms SQLite picks idx_unresolved_status, although failed is nearly every row: on vscode's index that read 830K rows, about 1 s, to select none. With the + the query is three prefix ranges of idx_unresolved_name. A test pins the plan.
  • The retry resolves in row order, as a full index does. When two refs make the same edge, the one written first keeps its metadata.
    • Ghostfolio's markets page needs this: a child route lazily loads the class its layout route also renders, so the child's module ref and its layout: ref make one edge.
    • Before, the retry returned rows in name_tail index order, so sync kept the layout: copy where a fresh index keeps the module ref's.
    • This only reorders refs that were already being retried.

Validation

Tests. __tests__/sync-route-module-retry.test.ts has 14 tests, and 13 fail without the fix (the one guard test passes either way). Every sync result is checked against a fresh index in a separate folder: indexAll() over an existing index skips unchanged files, so it can't stand in for one. The cases:

  • Module added after the router.
    • React Router: an extension-less import, .tsx, a default export named unlike its file, a folder's index.tsx, a tsconfig @/ alias rendering the Component export, and a lazy layout route.
    • Vue Router: .vue and extension-less imports, plus a lazy layout.
    • Angular: .then((m) => m.X), a default export, and a lazy layout.
    • Ghostfolio's child-loads-its-layout's-class shape.
  • An edit gives the module its component, for all three routers.
  • A deleted module comes back.
  • An index parked by v13, which migrates when opened.
  • Unit tests for the tails and keys, the v14 rewrite and replay, and the v14 plan.

Two of #2403's expectations in sync-path-reference-retry.test.ts move here. lazy-import:./routes/about.tsx and import:./home.component#HomeComponent are module references now, parked as module:about and module:home.

tsc is clean. On the rebased branch the affected suites pass, 1,076 tests: sync and retries, the React, Vue and Angular routers, extraction, and kernel-tsjs-parity with CODEGRAPH_KERNEL_EXPECT=1. The full suite (before the rebase) had 6,407 passed and 7 failed, all on this shared, fully loaded Windows box:

"Before" and "base" below are main at dd70646 with #2403 merged in. That is what main holds now, minus #2401, #2402 and #2407, which touch Go and C++ only. I re-ran three of the late cases and their fresh indexes on the rebased branch, and they are unchanged.

Full-index A/B (base vs fix) over 19 repos:

  • angular-realworld, berry, bulletproof-react, django-realworld, folo, ghostfolio, gin, infrahub's frontend, matx, luci's milo UI, ngx-admin, proshop, redux-realworld, takenote, ts-express, vben, vue-element-admin, vue-realworld, vue3-element-admin.
  • 17 are byte-identical.
  • folo and milo-ui differ only in the name_tail of 2 and 91 module refs that fail on a full index in both arms. milo-ui's lazy modules are barrels; see below.

Late module scenario. Every file a route lazily loads is held out, the rest indexed, the files restored, codegraph sync run, and the dump diffed against a fresh index. Differing dump lines:

Repo Modules held out Before Fix v13 index synced by the fix
angular-realworld 7 24 0 0
bulletproof-react 9 22 0 0
vue-realworld 7 24 0 0
vue3-element-admin 11 28 6 6
ghostfolio 30 65 0 0
milo-ui 36 148 8 8
vue-element-admin 55 126 0 0
infrahub (frontend) 64 380 0 0
vben 148 230 0 0

The residues in vue3-element-admin and milo-ui are aliased imports, and they show the same way in the before arm: import Error404 from '@/views/error/404.vue' and import { Component as GroupsPageWrapper } from …. I filed them as a follow-up. Ghostfolio reaches 0 only with the row-order retry; without it, 2 lines remain.

A generic late scenario, holding out every 7th source file, produces diffs that are byte-identical across base, fix without the sort, and fix. It ran on 8 repos including Go (gin) and Python (django-realworld), so the change leaves other retries alone.

Live watcher (cg.watch()): React, Vue and Angular routes link after the watcher's sync with the fix, and none link before.

codegraph affected src/pages/Team.tsx after the sync lists src/router.test.tsx with the fix and nothing before.

Cost on vscode's 1.9 GB index (830K failed refs): the v14 migration takes 11–16 ms. The module-key lookup for 1,000 changed files (2,989 keys) takes about 3 ms.

Schema version

This takes v14. Open PRs #2347 and #2060 still claim v12, which #2392 took (#2403 took v13). Whichever of them lands after this one needs to renumber past v14.

Not in this PR (filed as follow-up tasks)

  • Routes that render a same-file const Docs = lazy(() => import('./pages/Docs')) (from fix(react-router): read route tables another file hands the router #2400). With the module missing, the route binds to the const. When the module appears, sync keeps that binding unless the module's component shares the const's name, and the jsx-render edge isn't refreshed. This is a different mechanism: the ref resolved, so nothing is parked to retry.
  • Aliased import bindings (a default import, or X as Y) aren't retried when their module appears. That is the residue above; it also shows in ts-express, takenote and proshop on the generic scenario, identically before and after.
  • React lazy routes whose module is a barrel (export * from './build_page') never link, even on a full index. These are milo-ui's 91.
  • Minor: React's resolver claims Angular's layout: refs, so ghostfolio's edge carries framework: react. The target is the same; only the label is wrong.

🤖 Generated with Claude Code

colbymchenry and others added 6 commits October 7, 2026 01:05
…ppears later

A router names the page or layout it loads lazily by the module's path:
React Router's `lazy-import:./pages/Team`, Vue Router's and Angular's
`import:./home/home.component#HomeComponent`, each also behind `layout:`.
Sync retries a parked failed ref by name_tail (#1240), and these were
parked under a fragment of the path ('/pages/Team',
'component#HomeComponent') that no file's keys match. So a route whose
module was added after the router was indexed, or whose module gained its
component in a later edit, stayed unlinked until the router file changed
or the project was indexed again. #2392 fixed the same gap for imports,
#2403 for Liquid's path references.

- referenceNameTail parks a module reference under the stem of its path
  behind 'module:' ('module:Team'), a key no symbol's name can be, so name
  lookups never retry it by a namesake.
- Sync adds the changed files' moduleReferenceKeys (importPathKeys behind
  'module:') to the symbol retry: an added file can be the module, and an
  edit can give the module the component the route renders.
- Schema v14 rewrites the tails parked before. Its select keeps SQLite off
  idx_unresolved_status with `+status`, reading idx_unresolved_name ranges
  instead of every failed row (vscode's index: about 1 s down to ~12 ms).
- The retry resolves in row order, as a full index does, so when a route's
  module reference and its layout reference make the same edge, sync keeps
  the one a fresh index keeps (ghostfolio's markets page).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit 388b252 into main Oct 7, 2026
colbymchenry added a commit that referenced this pull request Oct 7, 2026
…n the module appears later (#2450)

A default, namespace or aliased import binds a name its module never
declares: `import tagsController from './tag/tag.controller'`,
`import { Component as GroupsPageWrapper } from './groups_page'`. Every
reference through that name (the import, a call, `new`, a base class, a
JSX or template tag, an Express mount) was parked under the name, which
no file a sync adds or changes carries. So a module added after its
importers were indexed, restored after a delete, or given its export in a
later edit never relinked them, while a fresh index did.

Such a reference is now parked under its project module's key
(`module:tag`), which sync already looks up for every file it adds or
changes (#2422), and it is still found by its own name: a binding its
module never resolves is linked by that name alone, as vben's
`{ VbenFormSchema as FormSchema }` from a workspace package is. Schema
v15 adds the partial index that by-name lookup needs; its leading
`status` keeps the planner off idx_unresolved_status, which read 830K
rows on vscode's index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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