vite icon indicating copy to clipboard operation
vite copied to clipboard

Bundling a Vite app is creating unexpected circular dependencies

Open kevinfarrugia opened this issue 6 months ago • 6 comments

Describe the bug

When using manualChunks to define a custom chunking strategy it is resulting in unexpected circular dependencies. In the repro provided, I am creating a separate chunk for each component and a framework chunk. This is only for demonstration purposes, but the issue is present even with a less aggressive chunking strategy.

When building, the framework chunk is dependent on a component chunk even if there are no implicit dependencies. However, the chunking moves some common code (originating from lodash/isString into the component.Foo chunk and the framework chunk then imports it.

Ideally I would like to avoid these circular dependencies from occurring without having to be able to identify each one manually. For example, in this case I would not expect the following code to be added to the component:

function getDefaultExportFromCjs(x) {
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, "default") ? x["default"] : x;
}

I am unsure if this is a Vite or Rollup issue, so please excuse me if I opened the issue in the wrong repo.

Thank you

Reproduction

https://github.com/kevinfarrugia/vite-chunk-dependencies

Steps to reproduce

The scope of this project is to demonstrate the dependencies of a Vite project.

Installation

npm install

Usage

The dependencies of the project can be visualized using Madge. To generate a dependency graph, run:

npm run build && npx madge ./dist --json

The output will be:

{
  "assets/app-D6GzwUaY.js": [
    "assets/component.TodoForm-CZovwjkK.js",
    "assets/component.TodoList-44ZiI1-M.js",
    "assets/framework-CtvSml8s.js"
  ],
  "assets/component.Foo-D0Zme1m7.js": [
    "assets/component.Label-BySnBOvf.js",
    "assets/framework-CtvSml8s.js"
  ],
  "assets/component.Label-BySnBOvf.js": [
    "assets/framework-CtvSml8s.js"
  ],
  "assets/component.TodoForm-CZovwjkK.js": [
    "assets/component.Foo-D0Zme1m7.js",
    "assets/framework-CtvSml8s.js"
  ],
  "assets/component.TodoItem-CzBrJbZI.js": [
    "assets/component.Label-BySnBOvf.js",
    "assets/framework-CtvSml8s.js"
  ],
  "assets/component.TodoList-44ZiI1-M.js": [
    "assets/component.Label-BySnBOvf.js",
    "assets/component.TodoItem-CzBrJbZI.js",
    "assets/framework-CtvSml8s.js"
  ],
  "assets/framework-CtvSml8s.js": [
    "assets/component.Foo-D0Zme1m7.js"
  ],
  "assets/index-CWx-HeYK.js": [
    "assets/app-D6GzwUaY.js",
    "assets/component.Foo-D0Zme1m7.js",
    "assets/component.Label-BySnBOvf.js",
    "assets/component.TodoForm-CZovwjkK.js",
    "assets/component.TodoItem-CzBrJbZI.js",
    "assets/component.TodoList-44ZiI1-M.js",
    "assets/framework-CtvSml8s.js"
  ]
}

As can be seen from the preceding output, the assets/framework-CtvSml8s.js file is dependent on assets/component.Foo-D0Zme1m7.js, which is a component that is used in the assets/app-D5DJEYJu.js file. This is not desired because it creates a circular dependency. The assets/framework-CtvSml8s.js file should not depend on any components, as it is meant to be a framework file that provides utility functions and does not need to know about the components.

Looking at the contents of the framework-CtvSml8s.js file, we can see that it imports the getDefaultExportFromCjs from the component.Foo chunk:

import { g as getDefaultExportFromCjs } from "./component.Foo-D0Zme1m7.js";

This is defined as:

function getDefaultExportFromCjs(x) {
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, "default") ? x["default"] : x;
}

This function is used within the component.Foo chunk in the following line:

const isString = /* @__PURE__ */ getDefaultExportFromCjs(isStringExports);

where isString is imported from lodash/isString. Removing the lodash/isString dependency from src/components/Foo/index.jsx will remove the circular dependency.

System Info

System:
    OS: Linux 6.11 Ubuntu 24.04.2 LTS 24.04.2 LTS (Noble Numbat)
    CPU: (24) x64 AMD Ryzen AI 9 HX 370 w/ Radeon 890M
    Memory: 43.97 GB / 62.09 GB
    Container: Yes
    Shell: 5.2.21 - /bin/bash
  Binaries:
    Node: 22.15.0 - ~/.nvm/versions/node/v22.15.0/bin/node
    npm: 10.9.2 - ~/.nvm/versions/node/v22.15.0/bin/npm
  Browsers:
    Chrome: 137.0.7151.55

Used Package Manager

npm

Logs

No response

Validations

kevinfarrugia avatar Jun 13 '25 13:06 kevinfarrugia

I noticed the discussion on https://github.com/vitejs/vite/issues/11804 . Albeit it is not directly related to this issue, I suspect that users are seeing the Failed to fetch dynamically imported module error more frequently when compared to Webpack because Vite invalidates the content hashes of most chunks even if there weren't any changes.

kevinfarrugia avatar Sep 29 '25 13:09 kevinfarrugia

@kevinfarrugia can you explain why circular dependencies isn't desired here? In practice, Rollup is able to analyze if a chunk was already loaded before and reuse some imports from it downstream, resulting in the circular dependencies, but they don't really affect the runtime.

But as you may notice this happens with manualChunks as some code are placed in a less optimal place, resulting in Rollup doing optimizations to reuse imports from upstream chunks. (But it's not a big issue in practice as explained above)

bluwy avatar Sep 30 '25 00:09 bluwy

@bluwy My concern is that it results in unnecessary cascading hash changes. In my example, if framework is dependent on the component chunk, then any changes in the component chunk will cause the framework chunk to be invalidated. This is because the framework chunk includes an import reference to the component chunk—to reuse the common getDefaultExportFromCjs.

Ideally, if I am changing the component chunk the framework chunk does not get invalidated (and framework should not be dependent on the component).

kevinfarrugia avatar Sep 30 '25 07:09 kevinfarrugia

Manual chunking issue might be solved by https://github.com/rollup/rollup/pull/6087, which introduced a new option onlyExplicitManualChunks https://rollupjs.org/configuration-options/#output-onlyexplicitmanualchunks

hi-ogawa avatar Sep 30 '25 07:09 hi-ogawa

Thank you for the pointer as I had missed the newly added onlyExplicitManualChunks.

I updated the repo to Vite 7 and ran a branch that includes onlyExplicitManualChunks: true.

Below is the output for npm run build && npx madge ./dist --json:

{
  "assets/app-BeYH7SUb.js": [
    "assets/component.TodoForm-DwrXFKgO.js",
    "assets/component.TodoList-Dcr9lvVa.js",
    "assets/framework-qz_M8Wt9.js"
  ],
  "assets/component.Foo-Cf-TVU7C.js": [
    "assets/component.Label-CcBu5ZEl.js",
    "assets/framework-qz_M8Wt9.js",
    "assets/index-DBOFRT_p.js"
  ],
  "assets/component.Label-CcBu5ZEl.js": [
    "assets/framework-qz_M8Wt9.js"
  ],
  "assets/component.TodoForm-DwrXFKgO.js": [
    "assets/component.Foo-Cf-TVU7C.js",
    "assets/framework-qz_M8Wt9.js"
  ],
  "assets/component.TodoItem-CETTHxK0.js": [
    "assets/component.Label-CcBu5ZEl.js",
    "assets/framework-qz_M8Wt9.js"
  ],
  "assets/component.TodoList-Dcr9lvVa.js": [
    "assets/component.Label-CcBu5ZEl.js",
    "assets/component.TodoItem-CETTHxK0.js",
    "assets/framework-qz_M8Wt9.js"
  ],
  "assets/framework-qz_M8Wt9.js": [
    "assets/index-DBOFRT_p.js"
  ],
  "assets/index-DBOFRT_p.js": [
    "assets/app-BeYH7SUb.js",
    "assets/component.Foo-Cf-TVU7C.js",
    "assets/component.Label-CcBu5ZEl.js",
    "assets/component.TodoForm-DwrXFKgO.js",
    "assets/component.TodoItem-CETTHxK0.js",
    "assets/component.TodoList-Dcr9lvVa.js",
    "assets/framework-qz_M8Wt9.js",
    "assets/index-DBOFRT_p.js"
  ]
}

This means that framework-[hash].js is now dependent on index-DBOFRT_p.js. Previously it was dependent on component.Foo-[hash].js.

I think this is a step in the right direction because component.Foo-[hash].js is a manual chunk defined in the manualChunks and as per definition of onlyExplicitManualChunks it won't merge dependencies from manual chunks.

However, the exported function was moved to a "non-manual chunk" index-DBOFRT_p.js that imports all chunks. Consequently, changing anything in the Foo component will invalidate the hashes of ALL chunks since they are all dependent on framework-qz_M8Wt9.js—that in turn is dependent on index-DBOFRT_p.js that itself is dependent on component.Foo-Cf-TVU7C.js.

kevinfarrugia avatar Oct 01 '25 12:10 kevinfarrugia

However, the exported function was moved to a "non-manual chunk" index-DBOFRT_p.js that imports all chunks.

Thanks for testing out. To me, this doesn't look like an expected behavior, so it might be worth raising an issue with rollup-only reproduction (and if not reproducible with rollup-only, there might be something to look into on Vite side).

Btw, getDefaultExportFromCjs is generated by internal helper module of commonjs plugin https://github.com/rollup/plugins/blob/ca4780995433fb55b709c91f28d5c13066ebf026/packages/commonjs/src/helpers.js#L21-L34, which Vite setups automatically. Though it didn't solve circular chunk, it might be good to isolate this fixed chunk too

        manualChunks: (id, { getModuleInfo }) => {
          if (id === '\0commonjsHelpers.js') {
            return 'helper'
          }

hi-ogawa avatar Oct 02 '25 03:10 hi-ogawa