blog.re-taro.dev
投稿日

React Compiler の opt-in と opt-out を整理する

先日に引き続き React Compiler のコードを読んでいる中で、opt-in と opt-out についての挙動がややこしいと感じたので整理してみた。

この記事で参照するコードの commit は c015abd である。

React Compiler の設定に、compilationMode というものがある。これは次のように定義されている。

const CompilationModeSchema = z.enum([
  /*
   * Compiles functions annotated with "use forget" or component/hook-like functions.
   * This latter includes:
   * * Components declared with component syntax.
   * * Functions which can be inferred to be a component or hook:
   *   - Be named like a hook or component. This logic matches the ESLint rule.
   *   - *and* create JSX and/or call a hook. This is an additional check to help prevent
   *     false positives, since compilation has a greater impact than linting.
   * This is the default mode
   */
  "infer",
  // Compile only functions which are explicitly annotated with "use forget"
  "annotation",
  // Compile all top-level functions
  "all",
]);

react/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts at d77dd31a329df55a051800fc76668af8da8332b4 · facebook/react · GitHub

The library for web and native user interfaces. Contribute to facebook/react development by creating an account on GitHub.

github.com
The library for web and native user interfaces. Contribute to facebook/react development by creating an account on GitHub.

opt-out はそもそも公式で明言された呼び方ではないということに留意しておく。opt-in の逆なんだから opt-out やろ!というのが私の勝手な解釈である。

デフォルトでは infer となっている。これは、コンポーネントやフックのような関数をコンパイルする。後者は、次の条件を満たす関数をコンパイルする。

  • componenthook で定義された関数

  • コンポーネントやフックのような関数であると推測される関数

    • フックやコンポーネントのような名前が付けられている

    • JSX を生成するか、フックを呼び出す

    • eslint-plugin-react のルールに一致する

  • フックやコンポーネントのような名前が付けられている

  • JSXを生成するか、フックを呼び出す

これらは ここ で実装されている。なかなか味わいのあるコードだ。

infer モードの嬉しさは、自動的にコンパイルされる関数を選択してくれることだ。

私が infer モードを opt-out と読んでいるのは後で詳しく説明する "use no memo" というディレクティブでコンパイル対象から外すことができるからだ。

annotation モードは、"use memo" というディレクティブが付与された関数のみをコンパイルする。

ドキュメントでも言及されている通り、opt-in はあくまで既に存在する React のプロジェクトに対して段階的に React Compoler を導入するための仕組みだ。

In rare cases, you can also configure the compiler to run in “opt-in” mode using the compilationMode: "annotation" option. This makes it so the compiler will only compile components and hooks annotated with a "use memo" directive. Please note that the annotation mode is a temporary one to aid early adopters, and that we don’t intend for the "use memo" directive to be used for the long term.

When you have more confidence with rolling out the compiler, you can expand coverage to other directories as well and slowly roll it out to your whole app.

--- https://react.dev/learn/react-compiler#existing-projects

さて、本題だ(?)。use memouse no memo というディレクティブがある。これらはどちらも関数やファイルの行頭に付与することでコンパイルの挙動を変更する。

混乱するのは compilationMode の値とその時の挙動である。例えば...

  • infer の時に "use memo" を付与した時

  • annotation の時に "use no memo" を付与した時

など、挙動はどうなるのだろうか?

解答はコードに隠されていた。

function getReactFunctionType(
  fn: BabelFn,
  pass: CompilerPass
): ReactFunctionType | null {
  const hookPattern = pass.opts.environment?.hookPattern ?? null;
  if (fn.node.body.type === "BlockStatement") {
    // Opt-outs disable compilation regardless of mode
    const useNoForget = findDirectiveDisablingMemoization(
      fn.node.body.directives,
      pass.opts
    );
    if (useNoForget != null) {
      pass.opts.logger?.logEvent(pass.filename, {
        kind: "CompileError",
        fnLoc: fn.node.body.loc ?? null,
        detail: {
          severity: ErrorSeverity.Todo,
          reason: 'Skipped due to "use no forget" directive.',
          loc: useNoForget.loc ?? null,
          suggestions: null,
        },
      });
      return null;
    }
    // Otherwise opt-ins enable compilation regardless of mode
    if (findDirectiveEnablingMemoization(fn.node.body.directives) != null) {
      return getComponentOrHookLike(fn, hookPattern) ?? "Other";
    }
  }
  switch (pass.opts.compilationMode) {
    case "annotation": {
      // opt-ins are checked above
      return null;
    }
    case "infer": {
      // Component and hook declarations are known components/hooks
      if (fn.isFunctionDeclaration()) {
        if (isComponentDeclaration(fn.node)) {
          return "Component";
        } else if (isHookDeclaration(fn.node)) {
          return "Hook";
        }
      }

      // Otherwise check if this is a component or hook-like function
      return getComponentOrHookLike(fn, hookPattern);
    }
    case "all": {
      // Compile only top level functions
      if (fn.scope.getProgramParent() !== fn.scope.parent) {
        return null;
      }

      return getComponentOrHookLike(fn, hookPattern) ?? "Other";
    }
    default: {
      assertExhaustive(
        pass.opts.compilationMode,
        `Unexpected compilationMode \`${pass.opts.compilationMode}\``
      );
    }
  }
}

react/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts at d77dd31a329df55a051800fc76668af8da8332b4 · facebook/react · GitHub

The library for web and native user interfaces. Contribute to facebook/react development by creating an account on GitHub.

github.com
The library for web and native user interfaces. Contribute to facebook/react development by creating an account on GitHub.

上記のコードコメントにある通り、ディレクティブが見つかった場合、compilationMode の値に関係なくディレクティブの指示に従う。つまり、"use no memo" が見つかった場合はコンパイルをスキップするし、"use memo" が見つかった場合はコンパイルを実行する。

React Compiler の opt-in と opt-out について整理してみた。compilationMode は opt-in と opt-out の挙動を制御する。"use memo""use no memo" はどちらもコンパイルの挙動を変更するディレクティブで、compilationMode の値に関係なく挙動を変更する。

React Compiler の中身を @yossydev@shun_shobon と読んでいるので、興味がある方はぜひ見てみてほしい。