Error [manualChunks is not a function]

TypeError: manualChunks is not a function の直し方(Vite 8 でオブジェクト形式が受理されなくなった)

FIX SUMMARY verified
Applies when
node:22.23.1-alpineVite 8.0.0 (production bundler switched to Rolldown; object-form output.manualChunks no longer accepted, function form deprecated)running vite build after upgrading Vite 7 to 8 with an existing build.rollupOptions.output.manualChunks object (the standard vendor-splitting recipe)

Verified: reproduced in node:22.23.1-alpine, then the manualChunks is not a function signature was gone after the fix (exit 0).

Vite 8 に上げたあと vite build がこの行で止まります。

TypeError: manualChunks is not a function

vite.config.js の manualChunks は関数ではなくオブジェクトで書いてあるはずです。それで正しく、Vite 7 までは動いていました。Vite 8.0.0 で本番ビルドのバンドラが Rollup から Rolldown に替わり、Rolldown はオブジェクト形式の manualChunks を受け取りません。

同じ行の少し上に、種明かしになる警告が出ています。

Warning: Invalid output options (1 issue found)
- For the "manualChunks". Invalid type: Expected Function but received Object.

検索で使う異表記:

  • manualChunks is not a function
  • Invalid type: Expected Function but received Object
  • Invalid output options (1 issue found)
  • Vite 8.2.1 では error during build: / Build failed with 1 error: の下に出ます。Vite 8.0.0 では捕捉されず、出力の末尾が Node.js v22.23.1 のような素の例外で終わります(行そのものは同じ)。

直し方

output.codeSplitting に移します。Rolldown が用意している置き換え先です。

// vite.config.js
export default {
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [
            { name: 'vendor', test: /node_modules/ }
          ]
        }
      }
    }
  }
}

groups の各要素は name(チャンク名)と test(モジュールIDに当てる正規表現)を取ります。CodeSplittingOptions は minSize / maxSize / minShareCount / includeDependenciesRecursively などの絞り込みも受け取ります。

置き場所は build.rollupOptions.output の下でも通りますが、そちらは非推奨です。 Vite 8 の移行ガイドは build.rollupOptions を build.rolldownOptions へ改名したものとして挙げており、設定リファレンスも rollupOptions を deprecated(rolldownOptions のエイリアス)と書いています。旧名でもビルドは通り、非推奨の警告も出ません。直すついでに新しい名前へ移すほうが、次の移行が 1 回減ります。同じアプリで両方を試すと、出るチャンクは名前もサイズも同じでした(vendor 189.23 kB)。

移行前の設定はこうでした。

// Vite 7 まで。Vite 8 ではこれが TypeError になる
manualChunks: {
  vendor: ['react', 'react-dom']
}

ただし、この書き換えは「同じ設定の言い換え」ではありません。 分けるモジュールの選び方が変わるので、vendor の中身が移行前と変わることがあります。次の節がその話です。

移行後に vendor の中身が変わることがある

manualChunks のオブジェクト形式と codeSplitting の test は、チャンクに入れるモジュールの選び方が違います。 前者は列挙したモジュールとその依存、後者はモジュールIDに正規表現を当てて一致したものです。両者が同じ集合になるとは限らないので、同じ「vendor を分ける」つもりでも結果が変わることがあります。

React を使う最小のアプリで測った例です。まず Vite 7.3.6 のまま、列挙する名前だけを変えます。

vendor: ['react', 'react-dom']         → vendor  11.21 kB / index 181.17 kB
vendor: ['react', 'react-dom/client']  → vendor 191.81 kB / index   0.86 kB

このアプリが import しているのは react-dom/client で、これは react-dom とは別のモジュールです。react-dom を列挙しても、そこから辿れる依存に react-dom/client は含まれない(import している向きが逆)ため、react-dom 本体は vendor に入らず index に残りました。

これを codeSplitting の test: /node_modules/ に移すと、node_modules 配下が一致するので、同じアプリで vendor は 189.23 kB になりました。['react', 'react-dom'] を test: /node_modules/ に置き換えるのは、同じ指定の言い換えではありません。

必ず変わるという話ではありません。 旧い列挙と新しい test が同じモジュール集合を指すなら、成果物は変わりません。実際、Vite 8 の関数形式(id.includes('node_modules'))と codeSplitting(test: /node_modules/)は、同じアプリでチャンク名もサイズも一致しました。変わるかどうかは、自分の旧い列挙と新しい正規表現が、同じモジュールを指しているかで決まります。

移行したら dist/assets の一覧を移行前と突き合わせてください。ファイル名にハッシュが付く構成では、チャンクの中身が変わるとファイル名も変わります。CDN のキャッシュやエラー監視のソースマップがファイル名に紐づいているなら、そこが影響を受けます。

関数形式の manualChunks

エラー文が Expected Function but received Object と言うので、オブジェクトを関数に書き換える手が最初に出てきます。実際それでビルドは通ります。

// 通る。が、これも非推奨
manualChunks(id) {
  if (id.includes('node_modules')) return 'vendor';
}

Vite 8.2.1 でこの形は終了コード 0 で完走し、vendor チャンクも出ます。ビルド出力に非推奨の告知は 1 行も出ません。

告知が出ないだけで、非推奨であること自体は配布物に書いてあります。node_modules/rolldown の型定義が manualChunks にこう付けています。

* Note that unlike Rollup, object form is not supported.
* @deprecated
* Please use output.codeSplitting instead.

つまり manualChunks は関数形式も含めて codeSplitting へ寄せる途中の API で、オブジェクト形式だけが先に受理されなくなった状態です。関数形式への書き換えはビルドを通しますが、移行ガイドと型定義が置き換え先として挙げているのは codeSplitting のほうなので、いずれもう一度書き換えることになります。

manualChunks と codeSplitting を両方書いたとき

codeSplitting が勝ち、manualChunks は無視されます。このときは警告が出ます。

 WARN  manualChunks option is ignored because the codeSplitting option is specified.

段階的に移行して両方が残った場合、ビルドは成功し、チャンクは codeSplitting の側の名前で出ます。

切り分け

  • vite build では出るが vite dev では出ない:manualChunks と codeSplitting はどちらも本番ビルドのチャンク分割の設定です。同じオブジェクト形式の設定のまま Vite 8.2.1 で vite dev を起動すると、開発サーバは待ち受けに入り、この TypeError は出ませんでした。設定を直したかどうかは vite build でしか分かりません。
  • エラーは出ないが vendor チャンクが消えた:manualChunks を消して codeSplitting を足し忘れた形です。codeSplitting の既定は true(自動分割)で、どちらも書かずにビルドすると終了コード 0 で index チャンク 1 つだけが出ます(同じアプリで 189.98 kB)。ビルドは成功し、名前を付けたチャンクだけが無くなります。
  • ビルドは通るのに、本番に console.log が残る:同じ Vite 8 移行で、止まらない側の変更を踏んでいます。esbuild: { drop: ['console'] } は Vite 8 では黙って無視されます(Vite 8 で console.log が本番バンドルに残る)。
  • Invalid type: Expected ... but received ... が manualChunks 以外の名前で出る:Rolldown の出力オプション検証は同じ形の警告を出します。entryFileNames に数値を入れると - For the "entryFileNames". Invalid type: Expected (string | Function) but received 123. になりました(このときビルドは終了コード 1 で失敗します。警告の形は同じでも、止まるかどうかはオプションによって違います)。For the "..." の引用符の中を読んで、どのオプションかを特定してください。
  • vite build が別の TypeError: ... is not a function で止まる:Vite 8 は CommonJS の default import の解決も変えています。条件が別(importer の拡張子や対象パッケージの持つフィールドで決まります)で、本記事では再現していません。移行ガイドの該当節を確認してください。
  • 依存を上げたら同期呼び出しが関数でなくなった:ツールの API が同期から非同期に変わる型は prettier3-resolveconfig-sync-removed が扱っています。あちらは版を戻さず await を足して直す形で、本記事のようにオプションの置き場所が変わる話ではありません。

検証(machine-verified)

この修正は node:22.23.1-alpine のバージョン固定コンテナ内で再現し、修正後に manualChunks is not a function のシグネチャが消えることを機械で確認しています。

verify — run-case.mjs
$ node run-case.mjs node/vite8-manualchunks-object-removed
● reproduce manualChunks is not a function present ✓
● apply fix exit 0
● re-run manualChunks is not a function gone ✓
PASS verified · node:22.23.1-alpine · signature gone

確認したのは上のイメージの中だけです。別の環境で直らなかった、記述が違う、という場合は 報告してください(対象と検証イメージは件名・本文に入ります)。