vite-plugin-react

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

4posts · 30d
Nov 30 posts
  1. Zenn · · @onclimb

    JSX が JavaScript になるまで React のコンポーネントには、こんなコードを書きます。 return <h1 className="title">Hello, {name}!</h1>; 見た目はほとんど HTML です。でも、よく見ると HTML ではありません。React の JSX では、属性は class ではなく className ですし、{name} のように JavaScript の式がそのまま埋め込まれています。かといって、JavaScript にも return の後ろにタグを書く構文はありません。 では、ブラウザはこのタグを HTML として読んでいるのでしょうか。それとも JavaScript として読んでいるのでしょうか。 答えはどちらでもありません。ブラウザに届く時点で、このタグはもう残っていないからです。筆者も「ビルドのときに何かに変換されている」ことは知っていました。ただ、何に変換されて、ブラウザが最終的に何を受け取っているのかは説明できませんでした。この記事では、変換の途中でできるもの(中間生成物)を 1 つずつ取り出しながら、.jsx に書いたタグが JavaScript になり、実行されて何を返すのかまでを追いかけます。 この記事の前提 環境: React 19.3.1 / react-dom 19.3.1 / Node.js v26.12.1 / macOS ブラウザでの確認: Vite 8.3.5(@vitejs/plugin-react 6.1.2)/ Google Chrome 156.0.8078.80 変換ツール: Babel(@babel/core 8.0.9・@babel/preset-react 8.0.1・@babel/parser 8.0.8)を主に使い、比較用に TypeScript 7.0.2 の tsc / SWC(@swc/core 1.16.20)/ esbuild 0.28.2 / Oxc(oxc-transform 0.156.0)も使います。TSX のコラムでは、ほかに @babel/preset-typescript 8.0.7・@babel/plugin-syntax-typescript 8.0.3・@types/react 19.3.0 を使います 対象: React の標準の変換方式(automatic runtime)。断りがない限り、開発用の出力(development)はオフにした結果を載せています 題材: 小さなコンポーネントを書いた App.jsx が中心です。TypeScript(.tsx)の場合に何が変わるかは、末尾のコラムで扱います 範囲外: 変換後のオブジェクトが Fiber になり DOM に反映される過程(reconcile)、Hooks の実装、Server Components 記事中のログ・エラーメッセージ・出力は、すべて筆者が上記環境で実際に採取したものです。AST のダンプと 5 つのツールの比較は、それぞれのツールを Node.js から呼ぶ小さなスクリプトによるものです 全体像 パーサー — JSX を構文として読める。ソースを AST(構文木)にし、タグを JSXElement などのノードとして表す。各変換ツールが内蔵している(Babel なら @babel/parser) 変換 — AST の JSX のノードを関数呼び出し(_jsx(...))に書き換え、呼び出し先の import を差し込む。Babel・SWC・esbuild・Oxc・tsc などが担う ランタイム(react/jsx-runtime) — 変換後の _jsx(...) の呼び出し先。React Element と呼ばれるただのオブジェクトを返す レンダラー(react-dom) — React Element を受け取り、ブラウザでは DOM を組み立てる(サーバーでは HTML 文字列にもできる)。この記事では中身には踏み込まない ブラウザが受け取っているのは、タグが関数呼び出しに置き換わった普通の JavaScript です。 その JavaScript を実行したあと、react-dom が DOM を組み立てます。 どの関数をどの形で呼ぶかは、JSX の構文自体には決まっていません。React の automatic runtime では、呼ぶのは react/jsx-runtime の jsx(type, props, key) で、この形は React が定めた約束です。変換ツールは、呼び出し先の設定(importSource など)で指定された先に向けて、この約束どおりに書き換えます。そのため、どの変換ツールを使っても、ほぼ同じ JavaScript になります。 課題 — React を書いていて浮かぶ疑問 冒頭の疑問ともう 1 つ、次の 2 つに答えます。 ブラウザは JSX のタグを HTML として読んでいるのか、JavaScript として読んでいるのか JavaScript はスクリプト言語で、書いたファイルをそのまま渡せば動くはずなのに、なぜ React のプロジェクトはデプロイの前にビルドが要るのか 題材は、Fragment(<>)・属性・埋め込みの式・イベントハンドラ・key が一通り入った小さなコンポーネントです。 // App.jsx import { useState } from "react"; export function App({ name }) { const [count, setCount] = useState(0); return ( <> <h1 className="title">Hello, {name}!</h1> <button onClick={() => setCount(count + 1)}> count is {count} </button> {[1, 2].map((n) => <li key={n}>{n}</li>)} </> ); } 試したこと — 中間生成物を段階ごとに取り出す 段階 0: JavaScript のエンジンは JSX を読めない まず、変換をせずにそのまま実行してみます。JSX を含むファイルを .mjs として Node.js に渡しました。 $ node stage0/hello.mjs const el = <h1>hi</h1>; ^ SyntaxError: Unexpected token '<' タグの 1 文字目 < で構文エラーです。拡張子を .jsx にした場合は、プロジェクトの設定で結果が変わりました。package.json が "type": "module" なら構文を読む前に ERR_UNKNOWN_FILE_EXTENSION で弾かれ、CommonJS なら .js として読まれて同じ SyntaxError になります。 変換をしない静的なサーバーから、次の HTML を Chrome で開きました。 <script type="module"> const el = <h1>hi</h1>; </script> コンソールには、Node.js と同じエラーが出ました。 SyntaxError: Unexpected token '<' JSX は ECMAScript(JavaScript の標準仕様)には入っていない構文拡張です。JSX 自体の仕様の草案(react/jsx、旧 facebook/jsx)も、エンジンやブラウザが実装することは想定していないと冒頭で断っていて、何に変換されるかも定めていません。つまり、ブラウザは JSX を HTML としても JavaScript としても読めません。JSX は、ブラウザに届く前に必ず誰かが JavaScript に変換している構文です。1 つ目の疑問への答えの半分がこれです。 2 つ目の疑問「なぜビルドが要るのか」の答えの中心も、ここにあります。ブラウザに渡す前に JSX を JavaScript へ変換する工程が要り、それを担っているのがビルドです。開発中に同様の変換をしているのは開発サーバーで、その様子は段階 2 の最後で見ます。 段階 1: パーサー — タグは JSX 専用のノードになる 最初の仕事は、JSX を読めるパーサーで AST を作ることです。Babel のパーサー(@babel/parser に jsx プラグインを付けたもの)で、return (...) の中身をダンプしました。長いので <h1> と <button> の部分を抜きます。 JSXFragment JSXOpeningFragment JSXText "\n " JSXElement JSXOpeningElement JSXIdentifier "h1" JSXAttribute JSXIdentifier "className" StringLiteral "title" JSXText "Hello, " JSXExpressionContainer Identifier "name" JSXText "!" JSXClosingElement JSXIdentifier "h1" JSXText "\n " JSXElement JSXOpeningElement JSXIdentifier "button" JSXAttribute JSXIdentifier "onClick" JSXExpressionContainer ArrowFunctionExpression (略) JSXText "\n count is " JSXExpressionContainer Identifier "count" JSXText "\n " JSXClosingElement JSXIdentifier "button" (以下 map の JSXExpressionContainer) 普通の JavaScript の AST には無い、JSX で始まるノードが並んでいます。タグ名の h1 も、JavaScript の識別子を表す Identifier ではなく、JSXIdentifier という別の種類のノードです。大文字・小文字・ドット付きのタグを並べたファイルでも確かめました。 // Main.jsx import { App } from "./App.jsx"; export const page = <App name="World" />; export const tag = <app name="World" />; export const member = <ui.Button />; --- page JSXElement JSXOpeningElement JSXIdentifier "App" JSXAttribute JSXIdentifier "name" StringLiteral "World" --- tag JSXElement JSXOpeningElement JSXIdentifier "app" (略) --- member JSXElement JSXOpeningElement JSXMemberExpression JSXIdentifier "ui" JSXIdentifier "Button" Babel のパーサーでは、大文字の App も小文字の app も同じ JSXIdentifier でした。この時点ではまだ「HTML の要素か、自作のコンポーネントか」を区別していません(Oxc のように、パースの時点で区別するパーサーもあります)。 段階 2: 変換 — JSX が関数呼び出しになる App.jsx を Babel(@babel/preset-react、automatic runtime)で変換し、dist-jsx/App.js に書き出しました。 import { useState } from "react"; import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime"; export function App({ name }) { const [count, setCount] = useState(0); return /#PURE/_jsxs(_Fragment, { children: [/#PURE/_jsxs("h1", { className: "title", children: ["Hello, ", name, "!"] }), /#PURE/_jsxs("button", { onClick: () => setCount(count + 1), children: ["count is ", count] }), [1, 2].map(n => /#PURE/_jsx("li", { children: n }, n))] }); } タグが _jsxs("h1", { className: "title", children: [...] }) という関数呼び出しに書き換わりました。 2 行目の import は、ソースには書いていません。呼び出す関数(react/jsx-runtime の jsx / jsxs)がどこから来るかを書かないと動かないので、変換ツールが自分で差し込んだものです。_jsx は、その jsx を別名で import したものです。/#PURE/ は「戻り値を使っていなければ消してよい」というバンドラ向けの注釈(PURE 注釈)です。 出力には、ほかにも決まりがあります。 属性と子は 1 つのオブジェクト(props)にまとめられ、子は props.children に入る 改行を含む空白だけのテキストを除いて、ソースに書いた子が複数なら _jsxs、1 つ以下なら _jsx になる key は props から外れて第 3 引数になる(_jsx("li", { children: n }, n)。例外もありますが、ここでは扱いません) 大文字のタグは変数、小文字のタグは文字列 Babel のパーサーでは区別されていなかった大文字と小文字は、変換の段階で分かれます。Main.jsx の変換結果です。 import { App } from "./App.jsx"; import { jsx as _jsx } from "react/jsx-runtime"; export const page = /#PURE/_jsx(App, { name: "World" }); export const tag = /#PURE/_jsx("app", { name: "World" }); export const member = /#PURE/_jsx(ui.Button, {}); 大文字で始まる App は変数 App の参照に、小文字の app は文字列 "app" に、ドット付きの ui.Button はプロパティの参照になりました。コンポーネント名を大文字で始めなければいけないのは、このルールで HTML の要素名(文字列)と区別されるからです。 5 つのツールで変換して比べる 実際のプロジェクトで JSX を変換しているのは、Babel だけではありません。Next.js は既定で SWC を使い、Vite 8 では Vite 本体の Oxc が JSX を変換していて、@vitejs/plugin-react v6 は Babel への依存をなくしました。そこで、同じ App.jsx を Babel・SWC・esbuild・Oxc・tsc の 5 つで変換しました。設定は 3 つそろえています。automatic runtime、importSource は react、開発用の出力(development)はオフです。 生の出力は見た目がかなり違います。たとえば esbuild はこうです。 import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import { useState } from "react"; function App({ name }) { const [count, setCount] = useState(0); return /* @PURE */ jsxs(Fragment, { children: [ /* @PURE */ jsxs("h1", { className: "title", children: [ "Hello, ", name, "!" ] }), /* @PURE */ jsxs("button", { onClick: () => setCount(count + 1), children: [ "count is ", count ] }), [1, 2].map((n) => /* @PURE */ jsx("li", { children: n }, n)) ] }); } export { App }; import の順番、_jsx か jsx かという名前、PURE 注釈の有無と書き方、整形、export の形がツールごとに違います。そこで import 文と PURE 注釈を消し、名前の先頭の _ と export の形をそろえ、整形し直してから比べるスクリプトを書きました。消した import 文も、import 元は 5 つとも react/jsx-runtime で、違うのは並び順と別名だけでした。 App: 1 distinct normalized output(s) [tsc, babel, swc, esbuild, oxc] function App({name}){const[count,setCount]=useState(0);return jsxs(Fragment,{children:[jsxs("h1",{className:"title",children:["Hello, ",name,"!"]}),jsxs("button",{onClick:()=>setCount(count+1),children:["count is ",count]}),[1,2].map(n=>jsx("li",{children:n},n))]})} 5 つの出力は、書き方の差を除くと完全に一致しました。Main.jsx でも同じで、5 つのツールすべてが大文字のタグを変数、小文字のタグを文字列に分けていました。ui はどこにも定義していませんが、どのツールもそのまま変換しています。変換ツールは、名前の正体を調べていません。 実装は 5 つともまったく別物で、言語も JavaScript(Babel)・Rust(SWC・Oxc)・Go(esbuild・tsc)とバラバラです。それでも出力がそろうのは、関数の形(jsx(type, props, key))と、そこへの渡し方(子は props.children に、key は第 3 引数に、子が複数なら jsxs)までが React の決めた約束で、どのツールもそれに合わせて書き換えているからです。変換ツールはその約束どおりに呼び出しを書けばよく、他のファイルを見る必要がありません。 なお、開発用の出力をオンにすると、同じ正規化でも 4 通りに分かれました。tsc・SWC・esbuild・Oxc はデバッグ用のソースの位置(ファイル名・行・列)を同じ値で渡しますが、ファイル名を変数に入れるか(tsc は const、Oxc は var)直接書くか(SWC・esbuild)が違い、Babel 8 は位置の情報そのものを渡しません。 ブラウザに届く JavaScript を確かめる この形のままブラウザに届くことを、実際のプロジェクトでも確かめました。同じ App.jsx を Vite 8 のプロジェクトに置き、vite build の出力から App の部分を抜き出しました。Vite 8 の変換は、先ほどの 5 つのうちの Oxc です。 return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)(h1,{className:title,children:[Hello, ,e,!]}),(0,p.jsxs)(button,{onClick:()=>n(t+1),children:[count is ,t]}),[1,2].map(e=>(0,p.jsx)(li,{children:e},e))]}) minify(圧縮)で変数名が縮み、文字列の引用符もバッククォートになっています。p はバンドルに取り込まれた react/jsx-runtime を指す変数で、(0,p.jsxs)(...) は this を渡さずに p.jsxs を呼ぶための、バンドラの書き方です。形は Babel の出力と同じ jsxs("h1", { className: "title", children: [...] }) で、出力の中に <h1 という文字列は 1 つもありませんでした。 開発サーバー(vite)でも確かめました。Chrome が App.jsx を要求すると、Content-Type: text/javascript の JavaScript が返ってきます。ブラウザはモジュールとして読み込むファイルの種類を拡張子ではなく Content-Type で判断するので、名前が .jsx のままでも JavaScript として実行します。中身は開発用の _jsxDEV(...)(react/jsx-dev-runtime)の呼び出しでしたが、タグが残っていない点は同じでした。あわせて、import { useState } from "react" も、/node_modules/.vite/deps/react.js?v=… からの import と const useState = …["useState"] という形に書き換わっていました。react の npm パッケージは CommonJS で配布されていてブラウザではそのまま動かないので、Vite が事前に ESM へ変換しておいたファイルです。 ブラウザに届いているのは、タグが消えた関数呼び出しの JavaScript でした。これが 1 つ目の疑問の残り半分の答えです。 段階 3: ランタイム — jsx() はただのオブジェクトを返す では、jsx() は何を返すのでしょうか。変換後の App.js と同じ呼び方で react/jsx-runtime の jsx / jsxs を呼び、戻り値を出力しました(jsx は本来アプリから直接呼ぶ関数ではなく、ここでは観察のために呼んでいます。NODE_ENV=production での結果です)。 --- jsxs("h1", ...) { '$$typeof': Symbol(react.transitional.element), type: 'h1', key: null, ref: null, props: { className: 'title', children: [ 'Hello, ', 'World', '!' ] } } 返ってきたのは、ただのオブジェクトでした。これが React Element です。type にはタグ名の文字列 'h1'、props には属性と子がそのまま入っています。$$typeof の値 Symbol(react.transitional.element) は、このオブジェクトが React Element であることを示す印です。段階 2 で JSX は関数呼び出しになりましたが、その関数がやっているのは「この形のオブジェクトを作る」ことだけです。DOM の要素はまだどこにも作られていません。 コンポーネントでも同じです。App を、呼び出された回数を数える関数 Counted で包んで jsx() に渡すと、type に関数そのものが入ったオブジェクトが返ります。 --- jsx(Counted, { name }) { '$$typeof': Symbol(react.transitional.element), type: [Function: Counted], key: null, ref: null, props: { name: 'World' } } type is the function itself: true | component called: 0 この時点で Counted は 1 回も呼ばれていませんでした。<App /> と書いても、App() が実行されるわけではありません。「App という関数を、この props で呼んでほしい」と書いたオブジェクトができるだけです。いつ呼ぶか(呼ばないか)はレンダラーが決めます。 結果 最後に、Vite の開発サーバーで開いた Chrome のページで、react-dom が組み立てた DOM を見ました。ページ上で document.getElementById("root").innerHTML を評価し、#root の中身を文字列にして取り出したものです。 <h1 class="title">Hello, World!</h1><button>count is 0</button><li>1</li><li>2</li> ここで初めて、画面の要素(DOM)が出てきました。ブラウザでは HTML の文字列を作って流し込んでいるのではなく、react-dom が React Element をもとに DOM を直接組み立てています。className は DOM では class 属性になっています。className は DOM の要素が持つプロパティ名(element.className)に合わせた名前で、class 属性として反映するのはレンダラーの仕事です。 ここまでをまとめると、次のとおりです。 JSX は ECMAScript の構文ではなく、Node.js も Chrome もタグの 1 文字目で SyntaxError を出した(段階 0) Babel のパーサーは、タグを JSXElement・JSXText などのノードにした。大文字のタグも小文字のタグも同じ JSXIdentifier だった(段階 1) JSX は _jsx / _jsxs の呼び出しになり、呼び出し先(react/jsx-runtime)の import が自動で差し込まれた。大文字のタグは変数、小文字のタグは文字列になり、key は props から外れて第 3 引数になった(段階 2) 設定をそろえた 5 つの変換ツールの出力は、書き方の差を除いて一致した(段階 2) Vite のビルド出力にも開発サーバーの応答にも、タグは残っていなかった(段階 2) jsx() は $$typeof・type・key・ref・props を持つただのオブジェクトを返し、コンポーネント関数はこの時点で呼ばれていなかった(段階 3) DOM を組み立てるのは react-dom で、className は class 属性になった 学び JSX の変換は、設定が分かればファイル 1 枚で完結する。 呼び出し先(react/jsx-runtime)と呼び出しの形(jsx(type, props, key))がファイルの外で決まっているので、変換ツールは他のファイルを見ずに書き換えられる。だから開発サーバーは、ブラウザに要求されたファイルだけをその場で変換して返せる 変換後の JSX を実行すると、「何を描画してほしいか」を書いたただのオブジェクトができる。 コンポーネントを呼ぶのも DOM を作るのも、そのオブジェクトを受け取ったレンダラーの仕事 課題に挙げた 2 つの疑問に答えると、こうなります。 「ブラウザは JSX を HTML として読んでいるのか、JavaScript として読んでいるのか」 — タグのままでは、HTML としても JavaScript としても読めません(段階 0)。タグはブラウザに届く前に関数呼び出しへ置き換わっていて、ブラウザはそれを普通の JavaScript として実行します。画面の要素(DOM)は、その実行の結果としてレンダラーが組み立てたものです。 「なぜスクリプト言語の JavaScript なのにビルドが要るのか」 — 書いたファイルが、ブラウザがそのまま実行できる形になっていないからです。その代表が JSX で、JavaScript ではありません。開発サーバーは JSX だけでなく、from "react" のような名前だけの import も、CommonJS で配布されている react を ESM に変換したファイルの URL に書き換えていました。開発中はこの書き換えを開発サーバーがその場でしてくれますが、静的ファイルを配るだけのデプロイ先には、それをしてくれる仕組みがありません。だから、デプロイ前のビルドで済ませておきます。 JSX の挙動で迷ったときは、DevTools の Network タブで届いた App.jsx を開くなどして、変換後の JavaScript を一度のぞいてみるところから始めていきたいですね。 コラム: TSX の場合 — tsx → jsx → js なのか、tsx → ts → js なのか ここまでは .jsx で見てきましたが、TypeScript と組み合わせて .tsx で書くプロジェクトも多くあります。.tsx には型と JSX の 2 つが入っているので、JavaScript になるまでに「型を消す」と「JSX を変換する」の 2 つの変換が要ります。題材の App.tsx は、App.jsx に型を 2 か所足しただけのものです。 type Props = { name: string }; export function App({ name }: Props) { // (以下 App.jsx と同じ) 結論から言うと、どちらの順でもできて、結果は同じでした。automatic runtime では、2 つの変換は互いに独立しているからです(classic runtime では、型を消すときに使われていない import を消す処理が import React を消してしまうことがあり、完全には独立していません)。 まず tsx → jsx → js です。tsc の jsx: preserve は、App.tsx から型を消し、JSX は残したまま App.jsx を出します。 // tsc --jsx preserve の出力(App.jsx) export function App({ name }) { const [count, setCount] = useState(0); return (<> <h1 className="title">Hello, {name}!</h1> (略) これを Babel で変換すれば JavaScript になります。 もう一方の順番の tsx → ts → js も作れます。Babel に TypeScript の構文プラグイン(@babel/plugin-syntax-typescript。構文を読むだけで、型は消さない)を付けて JSX の変換をかけると、型が残ったまま JSX が関数呼び出しになりました。 // Babel(preset-react + TypeScript の構文プラグイン)の出力。型はまだ残っている type Props = { name: string; }; export function App({ name }: Props) { const [count, setCount] = useState(0); return /#PURE/_jsxs(_Fragment, { (略) これを esbuild で型を消して JavaScript にしました。2 つの経路の最終出力を、本文と同じスクリプトで直接変換の結果と比べると、すべて一致しました(出力の見出しは読みやすく書き換えています)。 App: 1 distinct normalized output(s) [直接(tsc), 直接(babel), tsx→ts→js, tsx→jsx→js, 直接(oxc)] TypeScript 7 の実装(typescript-go の typescript/v7.0.2 タグの internal/compiler/emitter.go)で、tsc の変換器(tsc の中で 1 つの変換を受け持つ部品)を並べている getScriptTransformers を見ると、型を消す変換器が先で、JSX の変換器が後でした。 // transform TypeScript syntax { // (中略) // erase types tx = append(tx, tstransforms.NewTypeEraserTransformer(&opts)) // (中略) } if jsxTransformEnabled { tx = append(tx, jsxtransforms.NewJSXTransformer(&opts)) } つまり tsc は tsx → jsx → js の順で、jsx: preserve は、この並びから JSX の変換器だけを外した姿です。ただし中間のファイルはできず、どの変換器も前の変換器が返した AST を受け取り、次の AST を返していくだけです。他のツールも中間のファイルは作りませんが、作りはツールごとに違います。たとえば esbuild は型注釈をパースの時点で読み飛ばすので、そもそも型が AST に残りません。 .tsx で差が出るのは、変換の順番よりも、パーサーの読み方と型検査のほうです。tsc は型を検査するために、import を辿ってコンパイル対象のファイル一式(Program)を集め、名前の正体を解決し、型を計算します。 .tsx という拡張子は、TypeScript のパーサーの読み方を切り替える。.ts では型アサーションとして通る <string>v が、.tsx では TS17008: JSX element 'string' has no corresponding closing tag になった。.tsx でジェネリックなアロー関数を <T,>(x: T) => x とカンマ付きで書くのはこのため tsc --traceResolution で見ると、ソースのどこにも書いていない react/jsx-runtime が、react より先に解決されていた。JSX の型を引くため tsc は @types/react の JSX 名前空間で JSX を検査する。h1 は JSX.IntrinsicElements という interface のプロパティとして解決され、className は HTMLAttributes のプロパティだった。class と書くと TS2322 … Property 'class' does not exist … Did you mean 'className'? になる(それでも JavaScript は出力される) 所要時間は全体で約 0.18 秒で、そのうち Check(型検査の工程)が約 0.16 秒、JSX の変換を含む書き出し(Emit)は 0.001 秒だった。--noCheck を付けると全体で 0.020 秒になる。React を含まない 1 行の .ts と比べると、React の型定義(@types/react の 3 ファイルと csstype)が Program に加わった分、計算された型の数が約 2 倍になっていた。この題材は skipLibCheck を付けていないので、増えた分のほとんどは JSX ではなく型定義ファイル自体の検査だ 変換はファイル 1 枚で終わるのに、検査は呼び出し先の型定義まで Program を広げないとできません。.tsx の JSX は、この 2 つの仕事の重さの差がはっきり見える場所でした。 コラム: React 以外の JSX JSX は Preact・Hono・Vue・Solid でも書けます。構文と AST(段階 0・1)はどのフレームワークでも共通で、違いが出るのは「何の関数を呼ぶか」(段階 2)と「その関数が何を返すか」(段階 3)です。 ! 以下は各フレームワークのドキュメントに基づく整理で、この記事では検証していません。 フレームワーク 変換のしかた(呼び出し先) 返すもの React react/jsx-runtime React Element(ただのオブジェクト) Preact preact/jsx-runtime VNode Hono hono/jsx/jsx-runtime HTML 文字列にできるノード Vue 3 専用の Babel プラグインが生成する関数呼び出し VNode Solid 専用のコンパイラが生成する DOM 操作のコード 実際の DOM ノード(ブラウザ向けの出力) React・Preact・Hono は、jsx-runtime という同じ形の約束の上で返すものだけを差し替えているので、変換ツールの呼び出し先の設定(importSource)を変えるだけで切り替わります。Vue にも vue/jsx-runtime はありますが、公式が案内しているのは専用の Babel プラグインです。Solid はこの約束を使わず、JSX を、DOM を直接組み立てるコードにコンパイルします。 参考 JSX の仕様草案: react.github.io React「Writing Markup with JSX」(className の由来): react.dev React ブログ「Introducing the New JSX Transform」(automatic runtime): legacy.reactjs.org Next.js Compiler(SWC): nextjs.org @vitejs/plugin-react の CHANGELOG(v6 で Babel への依存を削除): github.com/vitejs/vite-plugin-react/blob/main/pac… Oxc: oxc-project/oxc esbuild の設計(型注釈の読み飛ばし): github.com/evanw/esbuild/blob/main/docs/architect… typescript-go internal/compiler/emitter.go(v7.0.2): github.com/microsoft/typescript-go/blob/typescrip… Preact: preactjs.com Hono の JSX: hono.dev Vue「Render Functions & JSX」: vuejs.org Solid の JSX コンパイラ(dom-expressions): ryansolid/dom-expressions

  2. React Compiler 導入で画面が更新されなくなった話 〜既存SPAでの主要ライブラリとの衝突と解決の記録〜 ! 検証環境 React 19.2.8 / babel-plugin-react-compiler 1.0.0 Vite 8.1.5 @vitejs/plugin-react 6.0.4 @rolldown/plugin-babel 0.2.4 @babel/core 8.0.7 React Hook Form 7.82.0 TanStack Router 1.170.18 TanStack Table 8.21.3 Vitest 4.1.10 Oxlint 1.75.0 記事中のコード・挙動・リンクはすべて上記バージョンでのものです。公開時点では TanStack Router・Oxlint などに新しいバージョンが出ており、一部の挙動や設定は変わっています。該当箇所ではその旨を注記しています。 はじめに こんにちは☀️ 株式会社AI Shiftでフロントエンドエンジニアをしているさっちゃんです!(X:おさわ / 吉岡 爽) 今回は、プロダクトに React Compiler を導入した際に UI に発生した不具合と、その対処について書きます。 私達のプロダクトでは React を採用し、Vite と TanStack Router を用いて、SPA を構築しています。フォームには React Hook Form、一覧には TanStack Table を使っています。 React Compilerの導入にあたり、導入前の実装をCompilerのON/OFFで比較しました。その中で、React Compiler を有効にしてテストを実行したところ、いくつかの箇所で異常が検知されました。これをきっかけに実際の画面上で動作確認を行った結果、サイドバーが画面遷移に追従しないなど、ユーザーの操作が意図通りにUIへ反映されない不具合が発覚しました。 この記事では、実際の画面の操作へ影響していたこのサイドバーの問題を中心に、設定・切り分け・原因・対処を紹介します。 この記事について 想定読者:React Compiler の導入を検討している方 書くこと 既存の SPA に React Compiler を導入したときの設定 導入前の実装に Compiler を適用し、ON/OFFで挙動を比較した方法 TanStack Routerの不具合と、React Hook Form の挙動変化、その原因・対処 テストの失敗と、実際の画面への影響を分けて確認したこと 書かないこと React Compiler 導入による性能改善の計測結果 React Compiler の内部実装の詳細 SSR / React Server Components 環境での挙動 導入設定 まずは、今回の Vite 8 + Babel プラグイン環境で React Compiler を導入した際の設定を紹介します。 パッケージの追加 React Compiler を導入するため、以下のパッケージをインストールします。 pnpm add -D -E babel-plugin-react-compiler@1.0.0 @rolldown/plugin-babel@0.2.4 @babel/core@8.0.7 Vite Ver.8 ではバンドラに Rolldown、JS/TS や JSX の変換に Oxc が使われています。 また、 @vitejs/plugin-react v6 では内部の Babel 依存が排除されました。そのため、今回使用した v6.0.4 で、Babelプラグインとして提供される React Compiler を適用するために、@rolldown/plugin-babel を組み合わせました。 ! @vitejs/plugin-react 6.1.0 以降では、oxc-transform-react を用いて Babel なしで Compiler を有効化する実験的な機能も追加されています。 react({ compiler: true }) この記事では、より実績があり安定している Babel プラグイン版を対象とします。 参考:@vitejs/plugin-react 6.1.0 (2026-08-19) CHANGELOG.md また、React Compiler はバージョンアップによってメモ化の挙動が変わる可能性があるため、予期せぬ不具合を防ぐ目的で -E フラグを使い、exact バージョンでインストールしています。 vite.config.ts の実装 Compilerに関係する部分を抜き出すと、次のようになります。 import { defineConfig } from "vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import babel from "@rolldown/plugin-babel"; const compilerPreset = reactCompilerPreset(); compilerPreset.rolldown.filter = { ...compilerPreset.rolldown.filter, id: { exclude: [/node_modules/, /.(test|spec).[jt]sx?$/] }, }; export default defineConfig({ plugins: [ react(), ...(process.env.REACT_COMPILER === "false" ? [] : [babel({ presets: [compilerPreset] })]), ], }); @vitejs/plugin-react が提供する reactCompilerPreset() をベースに、この設定に加えてLintも導入しました。 1. node_modules とテストファイルを変換対象から外す まず、node_modules 配下のライブラリは自分たちが管理しているコードではないため、変換対象から除外しました。 また、.test.* や .spec.* などのテストファイルも外しています。テストコード内で定義したモックやラッパーコンポーネントまで Compiler の対象になってしまうと、テストが失敗したときに、本体の変換による影響なのか、テスト側の変換による影響なのかを切り分けることが難しくなるためです。 2. 環境変数で無効化できるようにする なにか不具合が起きたとき、それが元のコードの問題か、Compiler による変換の影響かを切り分けやすくするために、REACT_COMPILER=false という環境変数を渡すことで、Compiler を無効化した状態で起動・ビルド・テストができるようにしました。 Vitest でも Compiler が適用されているか確認する 先ほどテストファイル(.test.ts など)自体は変換対象から除外しましたが、テストを実行できても、テスト対象のコードにCompilerが適用されていなければ、導入後の挙動を検証したことにはなりません。 reactCompilerPreset() は、Vite の Environment API において「クライアント向けのビルド・変換時」にのみ Babel(Compiler)を適用するように初期設定されています。 Vitest 4 では、jsdom 環境は実際には Node.js 上で実行されるため、モジュールの読み込み自体は SSR(サーバー)側の仕組みを使います。しかし、Vitest 4 が Vite のプラグインパイプライン(Environment API)を呼び出す際、jsdom のようなブラウザの動きを再現する環境では 「コンポーネントの変換(Transform)処理においては Client 環境向けのプラグイン設定を適用する」 という設計になっています (Vitest のソースコード)。 そのため、開発者が vite.config.ts で applyToEnvironment などのフックを上書きして「常に全環境でBabelを動かす」といった設定をしなくても、自動的にテスト用のコンポーネントに Compiler が適用されます。 念のため、コンポーネントの関数を文字列化して、Compiler が挿入するキャッシュ(const $ = ...c(10) のような記述)が含まれているかを直接確認してみました。 import { CommandPalette } from "@/components/ui/CommandPalette"; test("Compiler が適用されている", () => { expect(CommandPalette.toString()).toMatch(/const \$ = .*.c)(\d+)/); }); 設定 結果 初期設定のまま 適用される REACT_COMPILER=false 適用されない 初めは、jsdom 環境には自動適用されないのではと疑い、applyToEnvironmentHook が常に true を返すように設定を上書きしていました。 しかし検証の結果、上書きしなくても正しく適用されることがわかりました。 3. Lint で Rules of React を守る React Compiler が正しく動くには、コードが Rules of React に沿って書かれていることが前提です。 あなたのアプリケーションでコンパイラを導入できるかどうかは、コードベースの健全性と React のルールをどの程度遵守しているかに依存します。 出典:React Compiler:安全に使用できるか? Compiler は、最適化を安全に適用できないと判断したコードについて、対象の最適化をスキップすることがあります。 ルールに違反している可能性のあるコードに遭遇した場合、アプリケーションの動作を変更するリスクを冒すのではなく、最適化を安全にスキップします。 出典:React Compiler:コンパイラの動作を理解する これを防ぐため、Oxlint の react/react-compiler ルールを error レベルで有効にして CI 等で検知できるようにしました。 { "plugins": ["react"], "rules": { "react/react-compiler": "error" } } これで Rules of React への違反をある程度未然に防げます。ただし、Lint が通ったからといって Compiler 由来の不具合が完全にゼロになるわけではありません。この記事で紹介する不具合も、Lint では検出できずテストで発覚したものでした。 ! ここで設定している react/react-compiler は、Oxlint 1.78.0 までのルールです。 1.79.0 で purity・refs・immutability などの個別ルールに分割され、react/react-compiler は削除されました(Oxlint CHANGELOG 1.79.0、Oxc 公式ブログ)。 1.79.0 以降でこの設定を書くと、Rule 'react-compiler' not found in plugin 'react' で設定ファイルの読み込みに失敗します。代わりに次のように設定してください。 { "plugins": ["react"], "categories": { "correctness": "error" } } 検証方法 テストが失敗した際、それが本当にReact Compiler の変換による影響なのかを正確に切り分ける必要がありました。 そこで、Compiler 導入前のコードベースに、設定・依存・比較用のテストを持ち込み、Compiler の ON/OFF 以外の条件を完全にそろえた状態を作って検証を行いました。 以下の手順は、Vitest に unit プロジェクトが定義され、jsdom 環境と、先ほどの Vite プラグイン設定を利用する構成を前提にしています。今回の設定では、extends: true で親の Vite 設定を引き継いでいます。関係する部分を抜き出すと、次のようになります。 // vite.config.ts // pluginsなどは、前述のCompiler設定と同じ export default defineConfig({ plugins: [ // 前述のViteプラグイン設定 ], test: { projects: [ { extends: true, test: { name: "unit", environment: "jsdom", globals: true, include: ["src/**/*.{test,spec}.{ts,tsx}"], }, }, ], }, }); 手順 導入直前のコミットを別ディレクトリに展開する git worktree add ../before <導入直前のコミット> cd ../before Compiler の設定・依存と、比較に使うテストを、導入後のコミットから持ってくる git checkout <導入後のコミット> -- package.json pnpm-lock.yaml vite.config.ts <対象のテストファイル> pnpm install Compiler 無効 REACT_COMPILER=false pnpm vitest run --project unit <対象のテストファイル> Compiler 有効 pnpm vitest run --project unit <対象のテストファイル> Compiler の切り替えには、前の章で用意した環境変数 REACT_COMPILER を使いました。 結果 フォーム・ナビゲーション・共通 UI に関わる77件のテストで比べた結果です(導入時点の計測)。 実装 Compiler 結果 導入前 無効 77件成功 導入前 有効 71件成功・6件失敗 対応後 有効 77件成功 失敗した6件は、次の2種類に分かれました。 種類 件数 症状 TanStack Router を使ったサイドバー 2件 SPA内の遷移後、子ページを含むグループが開かない React Hook Form を使ったダイアログ 4件 キャンセル直後、終了アニメーション中の入力欄に編集値が残る これらの失敗がテスト環境特有のエラーなのか、実際のUIに影響するものなのかを確かめるため、ブラウザ上で実際に操作して挙動を確認しました。 結果として、どちらも Compiler によって実際に画面の挙動が変わってしまった状態でした。ただし、ユーザーへの影響は異なります。サイドバーは現在地のリンクが見えなくなる一方、ダイアログは終了アニメーション中だけ値が残り、再表示時には初期化されていました。 対応の概要 見つかった2つの問題には、それぞれ次のように対処しました。 問題 今回の対処 次のステップ サイドバーのグループが開かない(TanStack Router) useMatchRoute を、毎回新しい関数を返すラッパーフック経由で使う 修正済みの TanStack Router v1.170.27 以降へ上げ、ラッパーを外す キャンセル直後に入力値が残る(React Hook Form) 対象のダイアログに "use no memo" を付け、Compiler の変換対象から外す reset() に keepFieldsRef: true を指定し、"use no memo" を外す どちらも、導入時点では既存の挙動を確実に保つことを優先した一時的な対応です。 特にダイアログの "use no memo" は、Compiler の最適化をあきらめることで不具合を避けている状態です。本来は、Compiler の最適化を受けたまま直せる keepFieldsRef に移行を検討しています。 なお、サイドバー側の "use no memo" は数行のラッパーフックにだけ付けており、サイドバーのコンポーネント自体は引き続き Compiler で最適化されています。 壊れたもの:サイドバーの外から移るとグループが開かない 症状 サイドバーでは、関連する子ページのリンクを、開閉できる見出しの下にまとめています(以下、グループと呼びます)。 現在のページがグループ内の子ページであれば、そのグループを自動で開き、現在地がわかるようにしています。 しかし、サイドバー以外の導線から、ページ全体を再読み込みせずに子ページへ遷移すると、ページ本体は切り替わるのに、サイドバーのグループが開きませんでした。 例えば、アプリ概要から設定グループ内の子ページへ遷移すると、ページ本体は切り替わるのに、サイドバーの設定グループは閉じたままになっていました。 👇 再現用の簡易アプリで、ダッシュボードのカードから「通知」へ移動したときの動き Before(修正前):ページは切り替わるが、サイドバーの「設定」は閉じたまま After(修正後):「設定」が開き、パンくずも「通知」に更新される サイドバーは、以下のように useMatchRoute() でリンクがアクティブかどうかを判定し、その判定関数をグループのコンポーネントに渡していました。 function NavEntryItem({ entry, params }: Props) { const matchRoute = useMatchRoute(); const isActive = (link: NavLink) => matchRoute({ to: link.to, params }) !== false; return <NavGroupItem entry={entry} isActive={isActive} />; } function NavGroupItem({ entry, isActive }: GroupProps) { const hasActiveChild = entry.children.some(isActive); // hasActiveChild が true になったらグループを開く // ... } 原因 useMatchRoute が返す関数は、URL が変わっても同じ参照のままだった 今回使用していた TanStack Router v1.170.18 では、useMatchRoute() の実装は次のようになっていました。型やオプションの処理を省略しています。 ! この節に掲載する Matches.tsx と router.ts のコードは、TanStack Router の実装から、説明に必要な部分を抜粋・簡略化したものです。 ライセンス:MIT License 著作権表記:Copyright (c) 2021-present Tanner Linsley 引用元コミットの LICENSE 全文 実際のソースコードMatches.tsx の実装 export function useMatchRoute() { const router = useRouter() useStore(router.stores.matchRouteDeps, (d) => d) // 👇 router インスタンスだけを依存配列にしている return React.useCallback((opts) => router.matchRoute(/* options を渡す */), [router]) } ブラウザ側ではルーターの状態を購読しているので、URLが変わると、このフックを使うコンポーネントは再レンダーされます。 一方、返す関数の依存配列は [router] なので、同じ router インスタンスを使っている間、URLが変わっても関数の参照は変わりません。 しかし matchRoute は、引数だけで結果が決まるわけではなく、「現在の URL」を読み取って判定を行っています。 ソースコードを確認してみると、router.matchRoute() は、呼び出された時点の現在地をルーターの内部状態から読み取っていました。 実際のソースコード router.ts の実装 // 👇 呼ばれた瞬間に、ルーターから現在地を取得している const baseLocation = this.stores.resolvedLocation.get() || this.stores.location.get() const match = findSingleMatch(next.pathname, /* ... / baseLocation.pathname, / ... */) 同じ matchRoute({ to: "/settings" }) という呼び出しでも、現在の URL によって戻り値が変わります。一致した場合はルートパラメータのオブジェクト、不一致なら false です。そのため、次の式は、アプリ概要ページでは false、設定ページでは true になります。 matchRoute({ to: "/settings" }) !== false; この「参照は同じでも、内部状態によって結果が変わる」という性質が、今回のメモ化と衝突しました。 Compilerによって何がキャッシュされたのかを確認する 実際のコンポーネントを babel-plugin-react-compiler@1.0.0 で変換すると、isActive の生成に次のようなキャッシュが入っていました。 // 【1】matchRoute や params の参照が変わったかチェック if ($[0] !== matchRoute || $[1] !== params) { t1 = (link) => matchRoute({ to: link.to, params }) !== false; // 新しく関数を作る $[0] = matchRoute; $[1] = params; $[2] = t1; // 作った関数をキャッシュに保存 } else { // どちらも変わっていなければ、前回の関数(キャッシュ)を使い回す! t1 = $[2]; } const isActive = t1; // 【2】NavGroupItem に渡す props(entry や isActive)が変わったかチェック if ($[3] !== entry || $[4] !== isActive) { t2 = <NavGroupItem entry={entry} isActive={isActive} />; // 新しいJSX要素を作る // ... } ここで登場する変数は、それぞれ次のものを表しています。 matchRoute:useMatchRoute() が返す、指定した遷移先が現在の URL に一致するか判定する関数です。一致するとルートパラメータのオブジェクト、不一致なら false を返します。 params:遷移先の URL を組み立てるためのルートパラメータです。IDやslug、環境などが入ります。同じID・環境内で概要ページから設定ページへ移る場合、これらの値は変わりません。 entry:サイドバーに表示する項目の定義です。グループの場合は、「設定」のような見出しや、配下に表示する子リンクの情報を持っています。現在のページが変わっても、グループの定義自体は変わりません。 $:Compiler がコンポーネントのインスタンスごとに用意するキャッシュ用の配列です。$[0] には前回の matchRoute、$[1] には前回の params、$[2] には生成した判定関数を保存しています。 t1・t2:Compiler が生成した一時変数です。t1 には isActive として使う判定関数、t2 には <NavGroupItem ... /> という JSX 要素が入ります。 今回の実装では、entry はコンポーネントの外に定義した固定の項目一覧から渡しています。また、Compiler によって、親が作る項目一覧の JSX もキャッシュされていました。今回のページ遷移ではそのキャッシュが再利用されたため、JSX に含まれる params オブジェクトも作り直されませんでした。このため、子が URL の変更を受けて再レンダーされても、entry と params は前回と同じ参照のままでした。 この結果を読み解くと、以下のようになります。 URL が変わり、useMatchRoute() の購読によって親が再レンダーされる matchRoute と params の参照が変わらないため、Compiler によって isActive が再利用される entry と isActive の参照も変わらないため、子へ渡す JSX が再利用される この経路では NavGroupItem の再レンダーが省略され、hasActiveChild が再計算されない 子ページへ移っても、グループが閉じたままになる Compiler なしなら、毎回のレンダーで matchRoute(...) が呼び直されるため、この問題は表に出ていませんでした。 ! この問題は TanStack Router 側でも報告されており(#4499)、v1.170.27 で修正されました(#8015)。 「対応の概要」のとおり、今後は、バージョンを上げて対応することも検討しています。 対処 毎回新しい関数を返すラッパーを作る useMatchRoute を直接使わず、次のラッパーフックを経由するようにしました。 import { useMatchRoute } from "@tanstack/react-router"; export function useReactiveMatchRoute() { "use no memo"; const matchRoute = useMatchRoute(); const reactiveMatchRoute: typeof matchRoute = (options) => matchRoute(options); return reactiveMatchRoute; } やっていることは、matchRoute を実行するだけの関数をレンダーのたびに新しく作って返すことだけです。 "use no memo" ディレクティブをつけることで、このラッパーフック自体が Compiler に最適化されるのを防いでいます。 "use no memo" は、関数単位で Compiler の変換を止めるディレクティブです。 これにより、URL が変わって useMatchRoute の購読によって再レンダーが走るたびに、呼び出し元には必ず前回と違う参照を持った新しい関数が返るようになります。 呼び出しは以下のように変更しました。 function NavEntryItem({ entry, params }: Props) { const matchRoute = useReactiveMatchRoute(); const isActive = (link: NavLink) => matchRoute({ to: link.to, params }) !== false; return <NavGroupItem entry={entry} isActive={isActive} />; } URLが変わると、元の useMatchRoute() の購読によって再レンダーが発生します。その際、ラッパーからは新しい関数が返ります。 その結果、呼び出し側のコンポーネントでは Compiler のキャッシュ判定 ($[0] !== matchRoute) が正しく外れるようになり、isActive の再計算も、<NavGroupItem> の再描画も正常に行われるようになりました。 挙動が変わったもの:終了アニメーション中の入力リセット 症状 各種フォームのダイアログにおいて、値を入力してからキャンセルでダイアログを閉じるアニメーションの中で、入力値が残ってしまっていてテストが落ちていました。 再度ダイアログを開くと残っていなかったため、ユーザーへの影響は少ないと判断しましたが、こちらも原因を調査して対処しました。 function AccountCreateDialog({ isOpen, onClose }: Props) { const { register, reset } = useForm({ defaultValues: { name: "" } }); const close = () => { onClose(); reset({ name: "" }); // ← ここでリセットしている }; return ( <Dialog isOpen={isOpen}> <input {...register("name")} /> <button onClick={close}>キャンセル</button> </Dialog> ); } 原因 原因は、React Compiler のキャッシュ、React Hook Form の reset() の仕組み、React の ref コールバックの仕組みの3つが組み合わさったことでした 実際のダイアログコンポーネントを React Compiler で変換すると、register("name") の呼び出しは次のようにキャッシュされていました(出力コードの抜粋)。 if ($[25] !== register) { // register の参照が前回と違う場合(初回レンダーなど) t12 = register("name"); // 関数を実際に呼び出して結果を取得 $[25] = register; // 今回の register の参照をキャッシュに保存 $[26] = t12; // 呼び出した結果をキャッシュに保存 } else { // register の参照が変わっていない場合 t12 = $[26]; // 関数を呼び出さず、前回の結果を使い回す } $: 今回はインデックス 25 の枠に register 関数の参照を、26 の枠にその実行結果が保存されていました。 t12: register("name") の実行結果(onChange や ref などが含まれたオブジェクト)が代入され、最終的に JSX 側で <input {...t12} /> のように展開されます。 Compiler は register 関数の参照が前回と同じなら、関数を呼び出さずに前回の結果を使い回す という最適化を行ってくれます。 この最適化が、React Hook Form の2つの仕様とぶつかりました。 React Hook Form が持つ2つの仕様 register 関数は常に同じ参照だが、呼んだ結果は毎回新しい useForm が返す register 関数は、最初のレンダーで一度だけ作られ、その後は再レンダーされても同じ参照のままです。 一方、register("name") を呼んだ結果(戻り値)は、呼ぶたびに新しく作られます。戻り値に含まれる ref コールバック(ref に渡す関数)が、毎回新しい関数になるためです。 今回の reset() は登録済みフィールドの参照を破棄する 今回は保持オプションを指定していないため、フォームの値をリセットするとともに、React Hook Form が内部で保持するフィールドの参照も破棄されます。 Reset the entire form state, fields reference, and subscriptions. (出典:React Hook Form 公式ドキュメント - reset) React Hook Form は、「直後の再レンダーで再び register("name") が呼ばれ、入力欄が登録し直されること」 を前提に動いています。 keepFieldsRef: true を指定すると、今回のリセットでフィールド参照を保持できます。後ほど、この方法も紹介します。 なぜ画面に古い値が残るのか Compiler が register("name") の呼び出しをスキップする register("name") の引数 "name" は固定値であり、register 関数の参照も再レンダー間で変わりません。 Compiler は「同じ関数を同じ引数で呼ぶなら結果も同じはず」と判断し、2回目以降のレンダーで register("name") の実行をスキップして前回の戻り値(ref コールバック等を含むオブジェクト)をそのまま使い回します。 React が ref コールバックを呼び直さない 同じ入力 DOM が維持されている再レンダーでは、ref コールバックの参照も同じなら、React は ref コールバックを呼び直しません。今回、終了アニメーション中はこの条件に当てはまり、入力欄の再登録と値の書き込みが行われませんでした。 React Hook Form が画面を更新できない React Hook Form は、この ref コールバックが実行されるタイミングで入力欄を内部に再登録し、reset() 後の値を DOM へ書き込んでいます。ref が呼ばれないため、DOM への書き込みも再登録も行われません。 その結果、終了アニメーション中の入力欄には編集した値が残りました。一方、閉じ切った後は入力 DOM がアンマウントされます。再表示時には同じ ref コールバックでも呼び出されるため、初期値が反映されました。 対処 今回のPRでは、従来の「キャンセル直後に入力をリセットする」挙動を維持するため、対象ダイアログを Compiler の変換対象から外しました。 register() と reset() を組み合わせて使うダイアログは、コンポーネントの関数本体の先頭に "use no memo" を書くように対処しました。 function AccountCreateDialog({ isOpen, onClose }: Props) { "use no memo"; const { register, reset } = useForm({ defaultValues: { name: "" } }); // ... } "use no memo" を指定したコンポーネントは、その内部で生成される JSX や関数(子コンポーネントへ渡す props など)も Compiler のキャッシュ対象外となります。 局所的に最適化の恩恵は受けられなくなりますが、これ以外の独立したコンポーネントは引き続き Compiler で最適化されます。 あわせて、「閉じてもう一度開いたあとも、入力と検証が正しく動く」ことを確かめるテストを追加し、キャンセル直後に入力欄が空になっているかだけでなく、フィールドが登録し直されているかまで確認するようにしました。 今回はダイアログがアンマウントされるため再表示時には正常に戻っていましたが、仮にページに残り続けるフォームだった場合、reset() 以降は React Hook Form と DOM の紐付けが完全に切れてしまい、送信(submit)や値の取得ができなくなるという、より深刻な不具合に発展していた可能性があります。 本来望ましい対処:keepFieldsRef 「対応の概要」で触れたとおり、今回の "use no memo" は一時的な対応です。 今回の挙動変化は、次の流れで起きていました。 reset() がフィールドの参照を破棄する そのため、次のレンダーで register("name") を呼んで登録し直す必要がある ところが Compiler が register("name") の呼び出しをスキップするため、登録し直されない "use no memo" は、3. の Compiler の変換を止めて、登録し直しが起きるようにする 対処です。 一方、1. の破棄そのものをやめて、登録し直しを不要にする 方法もあります。それが reset() の keepFieldsRef オプションです(v7.60.0 以降)。 reset(defaultValues, { keepFieldsRef: true }); このオプションを付けると、reset() は入力欄の登録を残したまま、登録済みの入力欄に直接値を書き込みます。 今回失敗した4つのダイアログでは、対応する reset() に keepFieldsRef: true を指定すると、"use no memo" を外してもキャンセル直後の入力値がリセットされました。 対処 よい点 気になる点 "use no memo" コンポーネントの先頭に1行足すだけ。React Hook Form の挙動を変えない そのコンポーネントは Compiler の最適化を受けられない keepFieldsRef: true Compiler の最適化を受けたまま直せる 対象フォームでフィールド参照を保持する設定が必要。条件によって表示・非表示が切り替わる入力欄がある場合は、影響の確認が必要 ! React 公式ドキュメントでは、"use no memo" は一時的な回避策であり、恒久的な解決策ではないとされています 恒久的な解決策ではなく、一時的なデバッグツールとして使用することを意図しています。 (公式ドキュメント)。 keepFieldsRef への移行や、React Hook Form 側の対応状況を見ながら、将来的には外していくのが望ましいと考えています。 壊れなかったけど先に手を打ったもの テストが落ちたり実際に画面が壊れたりしたわけではありませんが、React Compiler 導入にあたって、事前に手を打った(コードを変更した)箇所もあります。 特に、React Compiler の互換性のないライブラリとして、互換性が現在確認されていないライブラリについては、先行事例(React Compilerについて調べてみた、React Compiler導入で得られた効果と気をつけたいポイント)も参考にしながら、バグを踏む前に対策を入れています。 TanStack Table には "use no memo" を指定 TanStack Table(@tanstack/react-table)は、Compiler との互換性に問題があるライブラリとして名前が挙がっています。 そのため、DataTable コンポーネントには "use no memo" ディレクティブを指定し、安全のために Compiler の変換対象から外しました。これにより、このコンポーネント内で定義するカラム定義や配下の子コンポーネントへの props も Compiler の最適化対象外となりますが、 予期せぬバグを防ぐため現時点では互換性を優先しています。 なお、検証時点の TanStack Table は v8 です。公開時点では v9 がリリースされていますが、v9 と Compiler の組み合わせは今回検証していません。 RHF の watch() を useWatch() に変更 React Hook Form の watch() メソッドも、Compiler との組み合わせで問題が起きやすいことが公式から指摘されています。 そのため、フォームの値を購読してUIを切り替えるような箇所は、フックである useWatch() を使用する形へすべて書き換えました。 手動の useMemo の削除 CommandPalette などのコンポーネントで過去に手動で書いていた useMemo は削除しました。 これはバグ対策というより、「今後のメモ化は Compiler に任せる」という方針に基づいたコードの整理です。 まとめ React Compiler を導入し、一部の手動メモ化を Compiler に任せる形へ整理しました。しかし今回の検証で見えてきたように、外部ライブラリとの組み合わせでは、過剰なキャッシュが原因でバグを引き起こすことがあります。 今回は、Compiler の ON/OFF で挙動を比較し、変換結果とライブラリの実装を確認することで、必要な互換性対応を判断できました。 また、テストが失敗した場合も、どのタイミングで挙動が変わり、ユーザーの操作にどう影響するのかまで確かめることが大切だと感じました。 この記事での調査の過程や回避策のアイデアが、これから React Compiler をプロダクトに導入しようとしている方の参考になれば幸いです! 最後に AI Shiftではエンジニアの採用に力を入れています! 少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか? (オンライン・19時以降の面談も可能です!) 【面談フォームはこちら】

    1 like
  3. 【今日の脆弱性情報】 github.com/vitejs/vite-plugin-react/security/advi… @vitejs/plugin-rscの0.5.38未満にCritical脆弱性(CVSS 9.5)。特定の構成条件下で、認証なしのリモートコード実行が可能。 ViteでReact Server Componentsを使っていたら対象。

    7 likes · 278 views
  4. Bluesky · · @davy.quest

    I'm really looking forward to turning that one on in our monorepo at work! Sadly still blocked because of vite-plugin-react#1191

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

1
PackageVersionDownloads · 30dTrend · 30d
v6.1.2
≥190.3M

README badges

Embed a badge in your README or website.

Live mentions · last 30 days
vitejs/vite-plugin-react mentions in the last 30 days