👹

Viteでbuildした時だけReact.MutableRefObject.currentがreadonlyになる

2024/06/18に公開

概要

はっきり原因が分かったわけではないのですが、かなり厄介なトラブルだったのでメモを残しときます。いつもは問題点と解決策をさくっと乗せるのですが、今回は解決策を書いてもハテナなので、読みづらいですが順を追って説明させてください。

状況

フロントエンドにtypescript + Reactを使ったWEBアプリでビルドツールにViteを使っていました。

ローカルでだいたい開発が終わりサーバーにデプロイしたところ下記のエラーが出ました。

TypeError: Cannot assign to read only property 'current' of object '#<Object>'

スクショ載せとくのこんな感じです。

ローカルでvite devで開発していた時はでません。vite buildした時のみこのエラーが出ます。

エラーの起きたコードはこんな感じです。

  React.useEffect(() => {
    // ここ!
    currentEditingReportRef.current = firstReport;
    dispatch(setCurrentEditingReport(firstReport));
    return () => {
      currentEditingReportRef.current = undefined;
      dispatch(setCurrentEditingReport(undefined));
    };
  }, [firstReport, dispatch, currentEditingReportRef]);

currentEditingReportRefのtype宣言はこう。

export type ReportEditViewProps = {
  ...省略
  currentEditingReportRef: React.MutableRefObject<Report | undefined>;
};

生成してるところが間違ってたらtypescriptが教えてくれるので間違ってないはずですが念の為生成してるところはこんな感じ。

const currentEditingReportRef = React.useRef<Report | undefined>();

React.MutableRefObjectを生成してるのでcurrentの書き換えはできるはずですがエラーです。

コード全部載せてもややこしいのでどんな使い方をしてるか解説を箇条書きでします。

  • ReportのリストがありそこでRefを作成している。
  • ReportのリストでReportをクリックするとReport編集Viewが作成されリスト上に描画される。
  • Report編集Viewは[前へ][次へ]のボタンでページ送りができる。
  • Report編集Viewからリストに戻った時に見ていたアイテムまでスクロールさせるため、現在編集中のReportの参照をcurrentEditingReportRef.currentに持たせてReport編集Viewの中で参照している。

というような設計です。

解決方法

単純な話なのですが現在編集しているReportはグローバルで一つだけ分かればいいので、React.MutableRefObjectを使うのをやめてグローバル変数にしました。

ます下記のようなクラスを作りました。

misc/currentEditingReport.ts
import { Report } from '../states/UploaderViewState';

class CurrentEditingReport {
  private ref: Report | undefined;

  constructor() {
    this.ref = undefined;
  }

  get current() {
    return this.ref;
  }

  set current(report: Report | undefined) {
    this.ref = report;
  }
}

const currentEditingReport = new CurrentEditingReport();
export { currentEditingReport };

先ほどのコードをこんな感じに変更。

import { currentEditingReport } from '../misc/currentEditingReport';

...省略

  React.useEffect(() => {
    // ここ!
    currentEditingReport.current = firstReport;
    dispatch(setCurrentEditingReport(firstReport));
    return () => {
      currentEditingReport.current = undefined;
      dispatch(setCurrentEditingReport(undefined));
    };
  }, [firstReport, dispatch, currentEditingReportRef]);

推察

「Report編集Viewからリストに戻った時に見ていたアイテムまでスクロールさせる」機能を実現するため、リストのラッパーのDOMのRefをReport編集Viewに渡し、Report編集Viewの中で現在編集中のReportを探しそこまでスクロールする処理がありました。

  return (
    <>
      <ViewHeader
        onPop={() => {
          const reportElem = reportsWrapperRef.current!.querySelector(`#report-${currentEditingReport.current!.id}`);
          if (reportElem) {
            reportElem!.scrollIntoView({ behavior: 'smooth' });
          }
        }}
...省略

DOMのRefを取得してるところはこんな感じ。よくあるDOMのRefを取得するコードです。

const reportsWrapperRef = React.useRef<HTMLDivElement>(null);
...省略
<ReportsWrapper ref={reportsWrapperRef}>
...省略

ここで、戻った時に同じ

TypeError: Cannot assign to read only property 'current' of object '#<Object>'

がReactのコード上で発生しました。コードがminifyされてるので何をしてるのかよくわかりませんがこんな感じのコードです。

index-B63kQkss.js
import { g as getDefaultExportFromCjs } from "./_commonjsHelpers-CcAunmGO.js";
import { r as reactExports } from "./index-aC1ZMUrs.js";
var reactDom = { exports: {} };
var reactDom_production_min = {};
var scheduler = { exports: {} };
var scheduler_production_min = {};
/**
 * @license React
 * scheduler.production.min.js
 *
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */
...省略
function Lj(a, b) {
  var c = a.ref;
  if (null !== c) if ("function" === typeof c) try {
    c(null);
  } catch (d) {
    W(a, b, d);
  }
  // ここ!
  else c.current = null;
}

このあたりから推察するに

React.MutableRefObjectあるいはRefObjectを別のコンポーネントに渡し、Viteでbuildすると壊れる。

ということではないでしょうか?ちなみにuseRefした同じコンポーネント内で使ってるところではこのエラーは出ませんでした。

useRefの仕様はさらっと読みました。レンダリング中にcurrentを書き換えるなと注意書きはあるものの、今回はそれにあたらいないと思うんですよね。ただ、ドキュメントには「不安定になる」とあるので、buildした時とDEVサーバーの時と何か微妙に実行するタイミングがちがって、というのはあり得そうな気がしますが、それにしてもエラーはもう少し分かりやすく出そうな感じがします。

https://react.dev/reference/react/useState

確かに言われてみれば別のコンポーネントにRefを渡して使い回すっていうコードは見た記憶がないかも。

試したこと

ReactかVite、typescriptのバグだと思ったので最新にアップデートしましたがダメでした。

ライブラリ バージョン
React 18.3.1
vite 5.0.0
5.4.5 5.4.5

検索でこちらを見つけました。

https://stackoverflow.com/questions/78396834/cannot-assign-to-read-only-property-status-of-object-object-when-vite-bu

かなりシチュエーションもエラーも似ていますがMutableRefObjectではないのと、dynamicImportが肝になってそうなので原因は違いそう。ただ、実行のタイミングで、という所は似てそうな気がします。

感想

思いっきり仕様で見逃していたらすいません。ご指摘いただけると助かります。他にも情報あったらコメントお願いします。中途半端ですが時間もないしとりあえず動いたのでこの辺で諦めます。

今回思ったのはフロントエンドのビルドツールで、ビルドしたときだけエラーが出るっていうのは非常に怖いですね。今回はそれほど多く変更する必要がなかったけど、かなりスケジュールに影響が出そう。webpackが開発終了してViteが伸びてますが、ちょっと心配になりました。

https://npmtrends.com/turbo-vs-vite

余裕ができたらミニマムで再現する環境を構築してReactかViteにISSUE立ててみるのも手かと思います。ただ、どっちの問題なんだろ?まずはViteな気もしますが。

Discussion