Companion to b8b86d599e (the vite.config pin). Documents the symptom,
root cause, workaround, and what a structural fix would look like, so
the chunk pin can be lifted alongside the CVA pin in the follow-up PR.
7.2 KiB
Circular imports — TanStack migration tracker
Temporary tracking doc, peer to TANSTACK_MIGRATION.md. Captures every
circular-import finding (source or chunk-level) we hit during the
Next → TanStack migration so they can be lifted into a separate
follow-up PR later.
Why a separate PR?
The migration branch is already large and touches a lot of apps/studio
surface. Source-level fixes for cycles tend to span packages/ui and other
shared workspaces — they're easier to review, revert, and bisect when not
co-mingled with route migrations.
Convention going forward
- One fix per commit. Whenever we land a workaround or a real source fix for a circular-dep issue, it goes in its own commit (or a clearly isolated hunk) so it can be cherry-picked.
- Tag the source comment. Any code comment that explains the
workaround should be greppable — start the comment with
Circular-dep workaround:so we can find every instance with onegit grep. Then list the file/line in this doc. - Add a row in this doc. Every entry should record:
- what failed (symptom),
- root cause (source vs chunk-level),
- what we did about it,
- which commit holds the fix,
- what a "real" fix would look like, if different.
- Don't rewrite history to extract. When the follow-up PR is
prepared, cherry-pick the relevant commits onto a fresh branch off
master. The originals on the migration branch can stay as-is.
Findings
1. cva is not a function at SSR prerender — ui ↔ TreeView chunk cycle
Symptom. Build (pnpm --filter=studio build) fails at SSR prerender
of /:
TypeError: cva is not a function
at .../dist/server/assets/TreeView-zmu8GZxM.js:1482:27
The crashing line is the top-level cva(...) initializer for
TreeViewItemVariant inside packages/ui/src/components/TreeView/TreeView.tsx.
When it surfaced. Adding apps/studio/routes/project/$ref/database/triggers.tsx
(stage 4 of the database migration). The new shell pulls PageLayout,
NoPermission, and a permission hook into the SSR graph. That import shape
is enough to nudge Rolldown into a different chunk layout, which exposes
the latent cycle.
Root cause — chunk-level, not source-level. Verified:
madge --circular --include-npm packages/ui/index.tsx→ no cycles.madge --circular --include-npm apps/studio/routes/__root.tsx→ no cycles.- No
import ... from 'ui'insidepackages/ui/src/**(8 grep hits all in@deprecatedJSDoc strings). - No
[plugin rolldown:circular-dependency]warnings during the build.
The cycle exists only in Rolldown's chunk graph:
dist/server/assets/ui-*.js(the package barrel +cn/Input/cva/most utilities) importsTreeView's exports fromdist/server/assets/TreeView-*.js— becausepackages/ui/index.tsxdoesexport * from './src/components/TreeView'.dist/server/assets/TreeView-*.jsimportscn/Input/cvaback fromdist/server/assets/ui-*.js— because Rolldown placed those utilities into theuichunk even thoughTreeView.tsxreferences them via relative paths (../../lib/utils,../shadcn/ui/input,class-variance-authority).
ESM tolerates source-level cycles because every imported binding is a
live reference and most consumers read them from inside function bodies
that run after the module graph is fully evaluated. Chunks don't have
the same forgiveness — the cva(...) call sits at the top of the
TreeView chunk's body, runs before the ui chunk has finished
defining its Or (cva) export, and gets undefined.
Workaround landed. Pin class-variance-authority to its own chunk so
neither ui nor TreeView ends up holding it:
- File:
apps/studio/vite.config.ts - Form:
build.rollupOptions.output.manualChunkslambda that returns'class-variance-authority'for any module undernode_modules/class-variance-authority/. - Commit:
3662e52f62 feat(studio): migrate /database/triggers/* + force cva into own chunk (stage 4). - Note: the workaround is bundled with the stage-4 route additions in
the same commit. To extract for the follow-up PR, cherry-pick the
apps/studio/vite.config.tshunk plus its surrounding comment block.
Why no source fix? All non-breaking restructures we considered don't actually break the chunk cycle:
- Switching
TreeView.tsxto import from a different relative path — doesn't matter; Rolldown will still pool utilities into theuichunk. - Removing the
@deprecatedself-import comments — already not real imports, removing them is cosmetic.
The structural fixes that would break the cycle are all breaking changes to consumers across the monorepo:
- Split the
uibarrel into multiple sub-entries:ui/utils,ui/tree-view, etc. Touches everyimport ... from 'ui'site. - Drop
cn/utility re-exports from the mainuibarrel and force consumers to import them from a deeper path. Same blast radius. - Move
TreeViewout of the main barrel into its own sub-export. Only affectsTreeViewconsumers but still breaking for them.
A hypothetical upstream fix would be a Rolldown chunker change that detects the cycle and shuffles utilities into a third chunk automatically. None known at the time of writing.
Real fix tracking. None landed. If/when one of the structural changes
above is approved, this row should be updated with the commit and the
chunk-pin in vite.config.ts reverted in the same PR.
2. TypeError: e is not a function in folder-open-<hash>.js — ui ↔ Lucide icon chunk cycle
Symptom. Production runtime (Vercel deploy) shows a blank page and the console error:
Uncaught (in promise) TypeError: e is not a function
at folder-open-8foVFSby.js:1:45
Same shape for any other Lucide icon Rolldown happens to split into its
own chunk (folder-open, code, chevron-down, …). The chunk does:
import { wo as e } from "./ui-<hash>.js"
var t = e("FolderOpen", [[...]])
export { t }
— so wo is the exported createLucideIcon from the ui chunk, but
the live binding is undefined when the icon chunk's top-level
createLucideIcon(...) call runs.
Root cause. Same shape as entry #1. packages/ui re-exports Lucide
icons from its barrel, so the ui chunk transitively imports
lucide-react. Rolldown then splits individual icons into per-icon
chunks that import createLucideIcon back from the ui chunk — a
chunk-level cycle that leaves the binding undefined at evaluation time.
The collision is observable in the ui chunk: it contains both
function O(...) (Lucide's createLucideIcon) and unrelated var O = …
redeclarations from minification, with O as wo in the export list.
Workaround. apps/studio/vite.config.ts pins
node_modules/lucide-react/** into a dedicated lucide-react chunk
alongside the existing class-variance-authority pin. After the pin,
the lucide chunk only imports from rolldown-runtime and the per-icon
chunks disappear.
Real fix. Same options as entry #1 — either restructure
packages/ui so it doesn't re-export icons from its main barrel, or
wait for a Rolldown chunker improvement that handles the cycle
automatically.
Empty rows
(This section will fill up as we keep migrating. Add new entries above.)