Rails アプリの webpacker を shakapacker に移行した

[webpacker]:https://github.com/rails/webpacker
[shakapacker]:https://github.com/shakacode/shakapacker
[webpack]:https://webpack.js.org/

[ツクリンク プロダクト部 Advent Calendar 2023](https://qiita.com/advent-calendar/2023/tsukulink) の2日目です。

# 概要

[webpacker][webpacker] はもうメンテナンスされないので [shakapacker][shakapacker] に移行した。手順通りやればうまくいくけど、ハマったポイントもある。

# [webpacker][webpacker] はもうメンテされない


> Webpacker has been retired 🌅

[webpacker READMEより][webpacker]

[webpacker][webpacker] はもうメンテナンスされません。最後のコミットから1年くらい経っていて、READMEにも大きく書いてありました。理由は DHH さんが詳しく語っています。もう2年前ですね。

https://world.hey.com/dhh/rails-7-will-have-three-great-answers-to-javascript-in-2021-8d68191b

ひいては webpacker が依存している [webpack][webpack] もアプデできないことになります。 webpack に脆弱性があった場合こまったことになる！ 

そして webpacker の正規最新バージョンが使用している webpack は、サポート中の Node.js だと OpenSSL に関連したエラーが出て使えません。 具体的なバージョン情報を箇条書きでまとめると以下のようになります。

- webpacker の正規の最新バージョンは 5.4.4
  - https://rubygems.org/gems/webpacker/versions
  - rc版の 6.x 系はリリースされているけど正式な 6.0 はもう出ない
- webpacker 5.4.4 に含まれている webpack のバージョンは 4.46.0
  - https://github.com/rails/webpacker/blob/v5.4.4/package.json#L49
- webpack 4 は Node.js 17 以上と組み合わせると OpenSSL のエラーになる
  - 参考: https://zenn.dev/niccari/articles/ffdd621eb6be5a
  - 参考: https://github.com/webpack/webpack/issues/14532
  - (と思ってたけど 4.47.0 だと対応されてるっぽい。とりあえず Node.js に対応させるには webpack のアプデだけでいいかも。未検証)
      - https://github.com/webpack/webpack/releases/tag/v4.47.0
- Node.js のメンテナンス対象の最低バージョンは 18
  - https://github.com/nodejs/Release
  - 16 は 2023-09-11 に EOL になった

# 移行先

[webpacker][webpacker] が示した移行先は3つあります。


- [jsbuilding-rails](https://github.com/rails/jsbundling-rails/)
- [import-maps](https://github.com/rails/importmap-rails/)
- [shakapacker][shakapacker]

特に推奨されているのは上2つですが、 shakapacker は webpacker の正式の後継 gem なのでほぼコードそのままで移行でき、 shakapacker が示す手順ドキュメント通りやればだいたいは動きます。とりあえず webpack の最新に追従しておきたい、って思った時はこの手段を取れると思います。今回は shakapacker への乗り換えで行ってみましょう。

> Official, actively maintained successor to rails/webpacker.ShakaCode stands behind the long-term maintenance and development of this project for the Rails community.

[shakapacker の README.md より][shakapacker]



# 手順


## テスト書く

webpacker の移行にかぎらない一般的な手順ですがいちばん重要なことなので。

webpacker の使用箇所のテストケースを網羅的に書きます。新機能は何もないはずなのでリグレッションテストになるはず。 E2E の自動テストがあれば最高だけど、 E2E テストをちゃんと書くのは超大変かつ結構不安定になりがちなので、手動のテストケースも準備しておくといいと思います。

特に古いブラウザでも動かしたいって時はこの手順がいちばん大変かもしれません。反対に最新のブラウザで動けばいい場合は適当でもなんとかなるかも。

## webpacker 5 -> shakapacker 6 に移行する

shakapacker が示す移行手順書があるので、この通りやります。
[Upgrading from Webpacker v5 to Shakapacker v6](https://github.com/shakacode/shakapacker/blob/master/docs/v6_upgrade.md)

丁寧なドキュメントなのでこれで大体動くようになりますが、いくつかハマったのでそれらの解決方法を書きます。

### `javascript_pack_tag` は一度しか呼べなくなる

 `javascript_pack_tag` はひとつのリクエストにつき(1つのHTMLにつき)一度しか呼べなくなります。二つ以上の webpacker のエントリーポイントある場合は `append_javascript_pack_tag` をつかって統合することになりますが、それぞれのエントリーポイント(`<script>`)に別々の属性をつけることができなくなっています。 なので、以下のようなレガシーブラウザに対応した `type=module` `nomodule` による読み替えがあった場合動かなくなります(参考： [ES Modules への橋渡しとしての nomodule 属性](https://blog.jxck.io/entries/2017-06-21/nomodule-attribute.html))。


```erb
<% # こういうコードはあきらめるしかない %>
<%= javascript_pack_tag 'some_script_for_modern_browsers', type: 'module' %>
<%= javascript_pack_tag 'some_script_for_legacy_browsers', nomodule: true %>
```
 
 shakapacker のコード確認しても `<script>` タグの属性値としてセットされる `options` を複数保持する方法がなさそうなので正攻法ではあきらめるしかなさそうです


```ruby
  def javascript_pack_tag(*names, defer: true, **options)
    if @javascript_pack_tag_loaded
      raise "To prevent duplicated chunks on the page, you should call javascript_pack_tag only once on the page. " \
      "Please refer to https://github.com/shakacode/shakapacker/blob/master/README.md#view-helpers-javascript_pack_tag-and-stylesheet_pack_tag for the usage guide"
    end

    append_javascript_pack_tag(*names, defer: defer)
    non_deferred = sources_from_manifest_entrypoints(javascript_pack_tag_queue[:non_deferred], type: :javascript)
    deferred = sources_from_manifest_entrypoints(javascript_pack_tag_queue[:deferred], type: :javascript) - non_deferred

    @javascript_pack_tag_loaded = true

    capture do
      concat javascript_include_tag(*deferred, **options.tap { |o| o[:defer] = true })
      concat "\n" if non_deferred.any? && deferred.any?
      concat javascript_include_tag(*non_deferred, **options.tap { |o| o[:defer] = false })
    end
  end
```
[lib/shakapacker/helper.rb#L98-L115](https://github.com/shakacode/shakapacker/blob/409e46ae22f7dba8268c6fc4f91e480372cd3f59/lib/shakapacker/helper.rb#L98-L115)
`javascript_pack_tag_queue` にはいったエントリーポイント名を受け取った `options` で出力している。 一度しか呼べないチェックがあるので `options` を使い分けることはできなそう

```ruby
  def append_javascript_pack_tag(*names, defer: true)
    update_javascript_pack_tag_queue(defer: defer) do |hash_key|
      javascript_pack_tag_queue[hash_key] |= names
    end
  end
```
[lib/shakapacker/helper.rb#L182-L186](https://github.com/shakacode/shakapacker/blob/409e46ae22f7dba8268c6fc4f91e480372cd3f59/lib/shakapacker/helper.rb#L182-L186)
`append_javascript_pack_tag` はそもそも `options` をうけとらない





### `babel.config.js` をいじっている場合は削除しちゃダメ

> Remove `babel.config.js` if you never changed it. Configure your `package.json` to use the default:
>```js
>"babel": {
>  "presets": [
>    "./node_modules/@rails/webpacker/package/babel/preset.js"
>  ]
>}
>```
>See customization example the Customizing Babel Config for React configuration.


>if you never changed

を見落としていて動かなくなりました。たとえば独自に staging 環境などを追加してる場合にハマると思います。元のファイルを shakapcaker のデフォルト [preset.js](https://github.com/shakacode/shakapacker/blob/409e46ae22f7dba8268c6fc4f91e480372cd3f59/package/babel/preset.js) に則って直しましょう。


### node_modules 以下がデフォルトでコンパイル対象から除外される

どうしても古いブラウザの対応が必要なときなど、  node_modules 以下の npm package のファイル群もコンパイルしたくなると思いますが、 shakapacker はデフォルトの設定で node_modules 以下をコンパイルの対象から外しています。

```js:package/rules/jscommon.js
const { resolve } = require('path')
const { realpathSync } = require('fs')
const {
  source_path: sourcePath,
  additional_paths: additionalPaths
} = require('../config')

const inclusions = [sourcePath, ...additionalPaths].map(p => {
  try {
    return realpathSync(p)
  } catch (e) {
    return resolve(p)
  }
})

module.exports = {
  include: inclusions,
  exclude: [
    {
      // exclude all node_modules from running through babel-loader
      and: [resolve('node_modules')],
      // Do not exclude inclusions, as otherwise these won't be transpiled
      not: [...inclusions]
    }
  ]
}
```

[package/rules/jscommon.js](https://github.com/shakacode/shakapacker/blob/409e46ae22f7dba8268c6fc4f91e480372cd3f59/package/rules/jscommon.js)

なので、以下のような設定を webpacker.yml に書いておく必要があります。 `additional_paths` は上記の `not: [...inclusion]` になるので、こっちの設定の方が強いです。

```yaml:config/webpacker.yml
  additional_paths: [
    'node_modules'
  ]
```

### style-loader が mini-css-extract-loader で置き換えられた

[style-loader](https://webpack.js.org/loaders/style-loader/) が [mini-css-extract-loader](https://github.com/webpack-contrib/mini-css-extract-plugin) で置き換えられたので、 JS ファイル中で `import` していた CSS が JS として読めなくなっています。 [dev_server 設定だとどちらかを選べますが、](https://github.com/shakacode/shakapacker/blob/master/docs/style_loader_vs_mini_css.md) 本番環境と開発環境でコードが大きく変わるのも面倒が多いので `stylesheet_pack_tag` を適宜呼ぶようにしました。

### 開発環境でリロードしてもスクリプトが新しくならないんだけど

shakapacker 6 だと開発環境でもスーパーリロードしないとキャッシュが効いてしまう問題があります。これはスクリプトの URL に hash がつかなくなったためで、これは shakapacker 7 の `useContentHash` オプションで修正できるようになってます。

see also https://github.com/shakacode/shakapacker/issues/88



### Slow setup for development って警告が出るんだけど

Rails のログを見てると以下のような遅すぎ警告ログが流れるようになってます

```
Shakapacker::Compiler - Slow setup for development

Prepare JS assets with either:
1. Running `bin/shakapacker-dev-server`
2. Set `compile` to false in shakapacker.yml and run `bin/shakapacker -w`
```

これは webpacker の時から元々遅かったのを警告出すようにしたみたいです。なので無視しててもいいですが、言われた通りに `bin/shakapacker -w` とか使うと高速です。

see also https://github.com/shakacode/shakapacker/pull/6
webpacker の方の時にすでに指摘されていたのを shakapacker で巻き取ったものみたい
https://github.com/rails/webpacker/pull/3233

### がんばろう

ほかにもアプリケーションによって色々はまりどころが出ると思いますが、 shakapacker のドキュメントとコードを読んで地道に解決していきましょう。 shakapacker 自体がどんどん薄くなる方針っぽいので、割と解決しやすいと思います。

## テストする

まずは `rails assets:precompile` が通る状態になることです。

precompile できるようになったら最初に作ったテストケースを開発環境と本番に準ずる環境で実施します。テストが落ちたら移行ガイドと設定を見直します。古いブラウザをサポートしている場合はそのテストも忘れずに(というか古いブラウザをサポートしなくていい場合はコンパイルが通ってる時点で大体ちゃんと動く)。

あとは開発環境のコンパイルが通るか、 dev-server 周りの設定がうまくいっているか確認します。

# 終わり

中期的には Rails に乗るという意味で shakapacker をはがすのが良さそうですね。今回の手順でテストケースができたので、他の手を取るのも簡単になったはずです！