クローズしたPRのブランチをGitHub Actionsで自動的に削除する

GitHub で PR のクローズ時にブランチを自動削除するワークフローを作成しました。この記事ではそのワークフローを紹介します。

GitHub で開発していると、PR をマージせずにクローズする機会は多いと思います。この時、クローズした PR のブランチは自動的に削除されません。ブランチを削除しないまま PR をクローズし続けると、「いざ PR を出すぞ」と push すると古いブランチと衝突してブランチを切り直す手間が増えてくるでしょう。

とはいえ、自動化が必要なほどの手間ではありません。自分の管理するレポジトリでは気づいたときに他の人の分も自分がポチポチしており、そこまで課題は感じていませんでした。しかし他レポジトリで PR のクローズ時に必ずブランチを削除することとなり、自動化を試してみました。

ワークフローの実装

以下が実際のワークフローです。

name: "ブランチ削除"
on:
  pull_request:
    types:
      - closed

permissions:
  contents: write

jobs:
  delete-branch:
    runs-on: ubuntu-latest
    if: github.event.pull_request.merged == false
    steps:
      - uses: actions/checkout@v3
      - name: delete unmerged branch
        run: |
            git push origin --delete "${{ github.head_ref }}"

delete-branch-if-closed/.github/workflows/actions.yml at main · no-yan/delete-branch-if-closed · GitHub

シンプルですが、PR のマージ時に起動させない部分だけはわかりにくいかもしれません。

このワークフローは PR がクローズされた時に起動します。正確にいうと、PR をマージせずクローズした時と、PR をマージした時にも起動します*1。今回の要件でマージ時にブランチを削除する必要はないため、ジョブが起動しないように設定しています。

on:
  pull_request:
    types:
      - closed
jobs:
  delete-branch:
    if: github.event.pull_request.merged == false

GitHub Actions はジョブごとに課金されるため、不必要なときはジョブを起動しないことで課金額を抑えています。

まとめ

GitHub Actions でPRのクローズ時に自動でブランチを削除する方法について説明しました。こういったケースは自分なら自動化しないですが、こうして記事にしておくと役立ててくれる人がいるのかもしれませんね。

*1:PRはマージするかマージせずクローズするものというメンタルモデルがある人だと、この挙動に当惑するかもしれません

Reactのアーキテクチャを知る Scheduler編2

こんにちは、noyanです。React内部実装アドベントカレンダーの12日目です。

今日もSchedulerというパッケージのコードを読んでいきます。

前回、Schedulerの機能は現在と未来のタスク管理であり、workLoop関数がこの両方に深く関わっていることを確認しました。

今回はMin Heapの実装を確認します。というかヒープの勉強ログですね。

Heapとは

最も大きい・小さい値のpopが高速なデータ構造です。ここでは最小値を取り出すmin heapで例示します。

木の親ノードの値 <= 子ノードの値が任意のノードで成立している二分木があるとします。すると木の根は必ず最小値になるため、最小値は根を確認すれば良いです。

img

ヒープ (データ構造)

追加

新しい値を追加するときは、木の深さをなるべく増さないように子を2つ持たないノードに追加します。このとき、親ノード <= 子ノードの関係が崩れることがあります。その場合、子ノードの値を親ノードと交換し(再帰的に)、必ず小さい値が親ノードにあるようにします。

Pop

Popすると最小値を削除し、根を埋めます。素朴な発想では、子ノードの小さい方を再帰的に親ノードへ動かすと思います。これは(木の高さhに対してh-1は少なくとも)完全二分木であるという性質を壊す場合があり、実装上この性質を活かしたいので別の手法を取りましょう。

実際の実装では、深さhの葉の値(画像の25や100)をpopし根に代入します。こうすると消えるノードの位置が固定できます。

配列でヒープを表現する

このようにして完全二分木の性質を保つと木構造を配列で表現できます。

Heap[0]を根とし、子ノードを配列にpushし続けると、以下の性質が成り立ちます。

親ノードのインデックスをKとすると、K2K + 1 , 2K + 2が子ノードになる

実装では削除の際にArray.prototype.pop()を用い、最後の値が削除されるようにします。

Scheduler実装の面白いところ

あとは実装するだけなので、一般的な Min Heapと同じです。

function siftDown(heap, node, i) {
  let index = i;
  const length = heap.length;
  const halfLength = length >>> 1;
  while (index < halfLength) {
    const leftIndex = (index + 1) * 2 - 1;
    const left = heap[leftIndex];
    const rightIndex = leftIndex + 1;
    const right = heap[rightIndex];

    // If the left or right node is smaller, swap with the smaller of those.
    if (compare(left, node) < 0) {
      if (rightIndex < length && compare(right, left) < 0) {
        // 右 < 左 < 親
        heap[index] = right;
        heap[rightIndex] = node;
        index = rightIndex;
      } else {
        heap[index] = left;
        heap[leftIndex] = node;
        index = leftIndex;
      }
    } else if (rightIndex < length && compare(right, node) < 0) {
      heap[index] = right;
      heap[rightIndex] = node;
      index = rightIndex;
    } else {
      // Neither child is smaller. Exit.
      return;
    }
  }
}

function compare(a, b) {
  // Compare sort index first, then task id.
  const diff = a.sortIndex - b.sortIndex;
  return diff !== 0 ? diff : a.id - b.id;
}

ここで興味深いパフォーマンス改善が行われていて、rightIndex < lengthで配列外参照を防いでいる点がコアです。配列外参照をすると、v8は戻り値の型が同じであると想定した高速化が効かなくなり、たとえばv8のブログで出ている例では6倍パフォーマンスが落ちるそうです。

https://github.com/facebook/react/blob/main/packages/scheduler/src/SchedulerMinHeap.js

Here, the last iteration reads beyond the array’s length, which returns undefined, which taints not just the load but also the comparison: instead of comparing only numbers, it now has to deal with special cases. Fixing the termination condition to the proper i < array.length yields a performance improvement for this example (measured on arrays with 10,000 elements, so the number of iterations only drops by 0.01%).

https://v8.dev/blog/elements-kinds#avoid-reading-beyond-the-length-of-the-array

おわりに

次回はunstable_scheduleCallbackでtaskQueueに入るタスクや、schedulerが呼び出すReactのタスクについて見て、schedulerを終わりにしようと思います。

Reactのアーキテクチャを知る Scheduler編1

こんにちは、noyanです。React内部実装アドベントカレンダーの11日目です。

今日はSchedulerというパッケージのコードを読んでいきます。

なぜSchedulerについてここで学ぶのでしょうか。useState編を読まれた方は、Reactがhooksの更新検知と実行に関して不可知のまま議論が終わってしまったことに不満を抱かれているかもしれないと思っています。意図的にこの部分を無視したのは、レンダリング処理がかなり複雑で、新しい概念の羅列が人をoverwhelmすると思ったからです。新しい概念の名前だけ覚える形になるより、知的に面白く小さく始められる領域から進めていくほうが好ましいと考えるため、Schedulerという比較的小さなパッケージから読み進めましょう。

Schedulerは小さくても、Reactのなかでかなり重要なパッケージです。このパッケージはReactのアーキテクチャが難解になっている大きな要因です。このパッケージが片翼をになう優先度管理の概要を知ることでReactの構造がみえてくるでしょう。

Schedulerはなにをするのか

Reactはタスク処理の優先度を管理することで、インタラクティブな体験とパフォーマンスの両立を目指しています。シングルスレッドのプログラムで一般に、ある関数の実行中は別の関数を実行することはできません。ユーザーの入力に反応してレスポンスを返すプログラムは、ある関数が長時間実行されている間、ユーザーの入力に反応することができません。「終了ボタンをおしても終了しない??」という体験の裏には、シングルスレッドでは必ずしもユーザーの入力にすぐ反応できない制約が隠れています。

これを改善するには、「長時間たったら・ユーザーの入力があったら」自身の実行を中断するよう各関数が自律するようプログラムの仕方を変える必要があります。Schedulerはこの機能を担っています。

Reactのインタラクティブ性改善については、以前記事を書きました。こちらも参考にしてください。

脱線:テストコードリーディング

ReactチームはSchedulerは将来Reactから独立してパッケージ化することを検討していた(いる)ため、React特有の文脈から機能を類推することができません。加えてドキュメントも整備されていません。そのため前提知識なくコードを読むことになります。テストコードは、こういう時の文脈補完に役立ちます。

たとえば以下のテスト2つを観察すると、前者ではTaskが連続して処理される一方で、後者ではruntime.advanceTime(4999)が挿入されると、Taskの間にPost Message,Message Eventがおかれたことがわかります。

// packages/scheduler/src/__tests__/Scheduler-test.js
  it('multiple tasks', () => {
    scheduleCallback(NormalPriority, () => {
      runtime.log('A');
    });
    scheduleCallback(NormalPriority, () => {
      runtime.log('B');
    });
    runtime.assertLog(['Post Message']);
    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event', 'A', 'B']);
  });

  it('multiple tasks with a yield in between', () => {
    scheduleCallback(NormalPriority, () => {
      runtime.log('A');
      runtime.advanceTime(4999);
    });
    scheduleCallback(NormalPriority, () => {
      runtime.log('B');
    });
    runtime.assertLog(['Post Message']);
    runtime.fireMessageEvent();
    runtime.assertLog([
      'Message Event',
      'A',
      // Ran out of time. Post a continuation event.
      'Post Message',
    ]);
    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event', 'B']);
  });

これを他の知識と組み合わせると以下のことがわかります。

  • PostMessageを使ってschedulerを起動する
  • 一定時間を超えるとschedulerが終了する

このように、テストコードは振る舞いについて雄弁です。コードリーディング一般に、全体像の把握で困ったときは読んでみてください。

workLoop

Schedulerの役割は2つあります。

  • 現在のタスクを優先度順で実行する
  • 未来のタスクの管理

workLoop関数はこれら2つの根幹を担っているため、ここからコードを読み始めましょう。

なお、「現在のタスク」と「未来のタスク」のキューはそれぞれtaskQueue, timerQueueで管理されています。これらは優先度付きキューというデータ型を用いて優先度の高い順にデータを取り出せるようになっています。

peek(taskQueue): 最優先のタスクを参照

pop(taskQueue): 最優先のタスクを取得し、taskQueueから除外

現在のタスクを優先度順で実行する

workLoop関数の前半が担当しています。workLoop関数は優先順位が高い順にtaskのcallback関数を実行していきます。Reactの場合はこのコールバックにレンダリング命令が渡されていて、コールバックが実行されるたびにReactのレンダリングが走ります。

function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime;
  currentTask = peek(taskQueue);

  // currentTaskを処理し続け、taskQueueがなくなったら終了する
  while (currentTask !== null) {
    // schedulerをとめる?
    if (shouldYield) {
      break;
    }
    const callback = currentTask.callback;
    const continuationCallback = callback(didUserCallbackTimeout);

    // 現在のtaskを継続する?
    if (typeof continuationCallback === "function") {
      currentTask.callback = continuationCallback;
    } else {
      // 継続しなくてよい。currentTaskを更新する
      if (currentTask === peek(taskQueue)) {
        pop(taskQueue);
      }
      currentTask = peek(taskQueue);
    }
  }
  // 後半
}

将来の実行予約

前半部分のwhile文を抜けたので、taskQueueが空になったか、shouldYieldがtrueになりました。もしtaskQueueが空になった(現在やるべきtaskがない)なら、schedulerを一旦終了します。そうでなければtaskをすぐ再開できるようにします。

timerQueueは今実行する必要がないタスク群なので、schedulerの開始タイミングを指定します。

function workLoop(hasTimeRemaining, initialTime) {
  // 上記省略
    
  // 次呼ばれるタイミングの予約
  // ブラウザから戻ったらすぐ
  if (currentTask !== null) {
    return true;
  } else {
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      // 開始タイミングを指定
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
}

今回は次の事項について省略しました。

  • shouldYieldToHostで実行をとめる
  • 開始時間を迎えたときtimerQueueからtaskQueueへ移動

次はどのように優先度を取り出しているかについて扱いたいと思います。

React Forget:問題の所在はどこか

2日ほど時間があきましたが私は元気です。

ちょっと頑張れない期間と難しい部分のコードリーディングが重なってました。

頑張れないときに50%でアウトプットを出すことを許せない自分の在り方がぶきっちょだなあと感じますが、(自分にとって)自明なコードを並べるだけでコードリーディングと称したくはないので、今後も自分のキャパシティを拡大する方向性で作業していきたいと思います。

頑張れないときは頑張れないなりに、未来からみて意義のある作業ができるはずなので、そのあたりを考えていきたいです。

React Conf

昨日はReact Confを見ました。大変良く纏まっていて、React 18について教えてと言われたら最初に見てもらう資料に良さそうです。自分は未知だったReact forgetが出てきた部分を面白く見ました。

React forgetはReactをAOTコンパイルしてビルド時にレンダリングを最適化する技術のようです。Reactは今までレンダリング最適化がある種のプロフェッショナルスキルとして見られてきましたが、その部分を代替していく形になります。

Reactはレンダリング時にpropsが変わったら、Subtreeすべてをレンダリングしなおします。このため無関係の部分まで再計算が走りえます。今まではこの対策としてReact自身としてはcontext, useMemo, memoなどが、外部ではRedux, Recoilのような選択肢がありました。今後はReact forgetがメモ化処理を自動化して、人がuseMemo, memoを触ることを減らす方向性のようです。

Compiler:メモ化の問題はどこに由来するのか?

発表を受けた感想ですが、ReactがImmutabilityモデルを固持してコンパイラまで開発するのかという部分が気になっています。Reactはpropsの比較をShallow Equal(Object.is)で行うため、同値のオブジェクトでも再生成すると再レンダリングが走ります。そのため、現在メモ化の意義は「本当は再レンダリングされる必要がないけどReactのに合わせる」ことにフォーカスしています。

オブジェクトの同一性への関心は、本来はなかった余計な複雑さです。関数プログラミングパラダイムが実践的に有用であるとは思いますが、ImmutabilityモデルはやめてDeep Equalで比較するOptionをつけるほうが開発コストとして結局安いことはないでしょうか?あるいはmapのレンダリングだけ最適化を行う、なるべくオブジェクトをpropsに渡さないようドキュメント/Eslintを改善するなどでも構いません。これらを選択せずImmutabilityによる「癖」をそのまま維持し、コンパイラまで作成するメリットがどこにあるのかがわかりません。

メリットに関しては、今後RFCでどういう展望があるかとあわせて説明があると思います。ですがより不安なのはリリース速度です。

Share, Schedule, Speed.

Reactは目先の改善よりも数年後を見据えた改善をしているのは確かな一方、Reactの開発は遅れがちな面があります。SuspenseはCacheのリリースを待つと足掛け三年半以上かかることになるでしょう。コンパイラの計画もServer Componentsの計画が公表される前から存在しているため、同じくらい掛かっていることになります。

これまでのReactはそれでもよかったのですが、現在Reactがフレームワーク志向で様々な面をサポートしているため、開発の遅さが顕著になっています。Reactはブラウザ以外との互換性を考える点で他フレームワークと異なり、くわえて後方互換性を重視しているため、さらにリリースが遅れがちです。開発が遅いとメジャーフレームワークの座を奪われるまでの時間が短くなることを意味します。Reactの新機能は大概「新しいパラダイム」を作ろうとしているため、毎回リリースが乾坤一擲となりがちです。もしServer Componentsが受け入れられなかったら、もっと「大衆に受けやすい」機能を追加したほうが良かったと考えるかもしれません。リリース速度が遅い現状で毎回伸るか反るかの勝負をするReactの戦略を結構ハラハラしながら見ています。

フレームワークとの競争力を保ちながらReactの現在の姿勢を維持するためには、もっとリリーススピードを上げる必要があるでしょう。ただし今のReactのアーキテクチャは複雑すぎて、正直外部から気軽にコミットできるものではないと考えます。そうなるとReact Core Teamが内部で後進を育ていけるかに掛かっているのですが、コミット数で見るとそこまでうまく行っていない印象です。

https://github.com/facebook/react/pulse

リーダーシップを発揮して働けている新規メンバーはRick Hanlonぐらいでしょうか。実装能力のあるメンバーがIssueハンドリングまでやっている印象で、その部分だけでも分担できればよいのですが(正直Andrew ClarkとBrian Vaughnの実装能力がすごすぎる)。

現在のコアチームの人数は、正確に把握していませんが10人に満たないはずで、大規模に使われているアプリケーションとしてはかなり小さなチームではないでしょうか。もう少し大きくなってほしいと思います。

自分はReactの問題解決手法自体にはかなり好感をもっているのですが、組織規模に対してかなり大きなことをやろうとしているために開発スピードが出ていない現状を憂慮していて、{これが続くと,時間をかけた機能の評判が悪いと,もっとコア開発の主力メンバーが増えないと}厳しいのではと感じています。

おわりに

React Conf の感想を長々と書きました。Reactへの期待感はこれまで通り強い一方、Reactが覇権を取り続けるためにリリーススピードが欠けているのではないかという懸念があったので、これを文章にしました。過剰に反応されると困っちゃいますが。

内省してみると、自分がコントリビュートすることを考えていない典型的な日本のOSSへの反応という感じもあるので、自分もReactが長続きするように頑張っていきたいです。

Reactのアーキテクチャを知る JSXのトランスパイル

こんにちは、noyanです。React内部実装アドベントカレンダーの6日目です。前回でuseStateが一段落したので、1回で終わる簡単な記事を書きます。

JSXはフレームワーク非依存のJavaScript構文拡張で、宣言的にUIを記述することを可能にします。多くの人は親しみがあるものの、中身について知らない人も多いのではないでしょうか。現に、React17で入ったJSXの新たな変換について「改善」として紹介しつつも、改善点を挙げていない記事がいくつか見受けられます。

もちろんJSXのトランスパイルを知らなければJSXを使えないなんてことはありません。しかしJSXの知識は、内部実装を読むにあたって必須の知識であるとともに、他

JSXの変換先

前述の通りJSXは拡張構文で、これはbabelなどを通して変換されます。Reactの場合、JSXは次のうちどちらかに変換されます。

// もともと
const Hoge = () => <div>hoge</div>;

// "runtime": "classic"
/*#__PURE__*/React.createElement("div", null, "hoge");

// "runtime": "automatic"
/*#__PURE__*/_jsx("div", {
  children: "hoge"
});=

この_jsxReact.createElementはだいたい同じことをしていますが、すでに知られているように利便性とパフォーマンスの面で_jsxが有利です。

React.createElement_jsxの違いに、_jsxはトランスパイル時にインポート文が自動挿入されるため自分でimport文を書かなくて良いことが挙げられます。内部的にはパフォーマンスを向上させたり、spread構文でkeyが渡ることを非推奨にする準備として_jsxを導入しているようです。

とくにkeyの取り扱いは面白くて、_jsxでは常に{...props}内にkeyがあればそれが優先されますが、

createElementではマージする順番でkeyが変わります。いずれkey spreadingは非推奨になる可能性があるので、多少記憶にとどめておくと良いでしょう。

// props = {key:11}のとき
<div key={12} {...props}/>
// key = 11

<div {...props} key={12}>
// key = 12

以上の動作は次のレポジトリから確認できます。

https://github.com/noyanyan/babel-jsx-transform

JSX構文が何にトランスパイルされるかはトランスパイラの設定で変えることができます。

https://babeljs.io/docs/en/babel-preset-react#docsNav

フレームワークのJSX変換については、babelのpragma, tsconfigのjsxFactoryを変えることで観察できます。vueに関してはpotato4dさんの記事が勉強になります。

https://d.potato4d.me/entry/20200830-tsx-in-vue/

ReactElement

細かい処理の違いはこれで終わりにして、jsxcreateElementはどちらもReactElementを返します。

これは5つのプロパティ($$typeof, type, key, ref, props, _owner)を持つオブジェクトを返します。

export function jsx(type, config, maybeKey) {
  return ReactElement(
    type,
    key,
    ref,
    undefined,
    undefined,
    ReactCurrentOwner.current,
    props,
  );
}

const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    // This tag allows us to uniquely identify this as a React Element
    $$typeof: REACT_ELEMENT_TYPE, // symbolFor('react.element')

    // Built-in properties that belong on the element
    type: type,
    key: key,
    ref: ref,
    props: props,

    // Record the component responsible for creating this element.
    _owner: owner,
  };
  
  return element;
}

https://github.com/facebook/react/blob/12bffc78d8d9e0ee8d494849f20611fe15d598ef/packages/react/src/jsx/ReactJSXElement.js#L210

おわりに

本記事ではJSX構文が_jsxを呼び出し、ReactElementを作成しているところを確認しました。

React.Elementは5つプロパティをもつオブジェクトをつくることを大仰に説明している感はありますが...。

Reactのアーキテクチャを知る useState編4

こんにちは、noyanです。React内部実装アドベントカレンダーの5日目です。昨日まで3回にわけてマウント時のuseState処理を追ってきました。ですが、詳細に関数の処理を追うことに注力したため、実際に関数コンポーネントと関数の知識が結びついていない感じがします。

以上を背景に、この記事では関数コンポーネントがマウントされてから値が更新されるまでの流れを概観し、useStateの全体像をつかみます。useStateのメンタルモデルを作成して、hookの挙動を想定できるようになることがgoalです。

関数コンポーネントが更新されるまで

次のような関数コンポーネントがあるとします。

 const Counter = () => {
    const [count, setCount] = useState(0);
    if(count === 0) setCount(1); 
    
    return (<div>{count}</div>)
 }

この関数が<div>1</div>となるまでの処理を追っていきましょう。これには主にrenderWithHooksの処理をおえば良いです。

1. useStateのdispatcherを決定

renderWithHooksが呼ばれると、ReactはuseStateに紐付くdispatcherを決定します。onMountではmountState関数が呼び出されると、hookを作成し[hook.memoizedState, dispatch]を返します。

export function renderWithHooks(
  current,
  workInProgress,
  Component,
  props,
  secondArg,
  nextRanderLane
): any {
  // Attach Dispatcher
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // Render components as long as it has any updates
  let children = Component(props, secondArg);

  if (didScheduleRenderPhaseUpdateDuringThisPass) {
    do {
      didScheduleRenderPhaseUpdateDuringThisPass = false;
      ReactCurrentDispatcher.current = HooksDispatcherOnRerender;

      children = Component(props, secondArg);
    } while (didScheduleRenderPhaseUpdateDuringThisPass);
  }
  return children;
}

2. コンポーネントレンダリング - mountState編

次にコンポーネント関数を実行します。

  let children = Component(props, context);

記事冒頭のCounterコンポーネントの場合で処理を追ってみましょう。

 const Counter = (props, context) => { // 引数には ({}, undefined)が入ることを期待している。
    const [count, setCount] = useState(0);
    if(count === 0) setCount(1); 
    return (<div>{count}</div>)
 }
 
 let children = Counter(props, context)

(第2引数のcontextは非推奨のLegacy Context APIで、現在標準のcontextの表記法とは異なります。)

見通しを良くするため、実行される関数に置き換えます。

  • useStateはマウント時mountStateに
  • setStateはdispatchに
 const Counter = (props, context) => {
    // 引数には ({}, undefined)が入ることを期待している。
    const [hook.memoizedState, dispatch] = mountState(0);
    //      ↑ === 0
    if(count === 0) dispatch(1)
  ); 
    return (<div>{hook.memoizedState}</div>);
 }
 
 let children = Counter(props, context)

mountState

Counter関数で最初に呼び出されるmountState関数は[0, setCount]を返します。この際、Reactは内部でhook.memoizedStateにmountStateの呼び出し値を記録し、次回呼ばれた際に返す値とします。

dispatch

dispatch関数はバインドされたdispatchSetStateです。dispatchSetState関数は更新キューをつくり、hook.queue.pendingに循環リストのかたちで付与します。

Counter関数でもsetState関数(=dispatchSetState)が呼ばれるので、dispatch(1)によりhook.queue.pendingに更新キューが作成されます。

(この部分でわからない箇所がある。dispatchSetStateはマウント時の関数が使い回されるため、この関数はマウント時のfiberをずっと参照している。するとマウント時以外はrerender更新は行われないことになり、これは即座の反映が行われないことを意味する。この理解は正しいのだろうか。)

3. コンポーネントレンダリング - rerenderState編

実はdispatchSetStateを実行するとdidScheduleRenderPhaseUpdateDuringThisPasstrueになるので、値がfalseになるまでレンダリングを続けます。アプリケーションコードとしては、setStateが呼ばれなくなるまで実行することを意味します。

 function renderWithHooks(){
   if (didScheduleRenderPhaseUpdateDuringThisPass) {
    do {
      didScheduleRenderPhaseUpdateDuringThisPass = false;
      ReactCurrentDispatcher.current = HooksDispatcherOnRerender;

      children = Component(props, secondArg);
    } while (didScheduleRenderPhaseUpdateDuringThisPass);
  }
  return children;
}

const HooksDispatcherOnRerender: Dispatcher = {
  useMemo: updateMemo,
  useReducer: rerenderReducer,
  useRef: updateRef,
  useState: rerenderState,
}

do while 構文内のDispatcher.useStateは、処理されていないqueueを順番に処理します。これが前回読んだrerenderStateの仕事です。結果的にhooksの値が更新されます。

const [count, setCount] = useState(0);
        ↑ === 1

やっと更新されました。

コンポーネントが最後まで実行され、childrenに値が代入されます。

children = <div>0</div>

さて、count === 1のときsetStateは実行されなかったため、didScheduleRenderPhaseUpdateDuringThisPassはfalseのままになり、do while文を抜けます。

最終的に更新された_jsxが最終的にReactElementを呼び出して、renderWithHooksの呼び出しが終了します。この後どうなるかは自分もわかっていないですし、急に知らなければいけない知識が多くなってしまうので今度にさせてください。

おわりに

本当はdo while文の後にcontextの最適化処理があるのですが、これは今回関係ないので省略しました。この話が今月のReact confで発表されると思われるので、楽しみにしています。

次回はすこし箸休めとしてdev-toolかjsxの変換の話をしようと思います。

Reactのアーキテクチャを知る useState編3

こんにちは、noyanです。React内部実装アドベントカレンダーの4日目です。昨日集中しすぎてうまく頭が働かないようなので、文章が荒かったらすみません。

引き続きReactのuseStateフックの内部実装を読み解いていきます。

これまで、マウント時のuseState()の挙動とsetStateにバインドされる関数を見てきました。今回は、setStateが呼び出された時、actionがどうやってstateに反映されるのかを見ていきます。

この記事が読み終わった時、setState(prev => prev+1)がどう処理されるのかを理解できることをゴールとします。

queueの行き先

setStateの正体であるdispatchSetStateは、前回の記事で見たところhook.queueにactionをenqueueしていました。このhook.queueはどの関数でstateに追加されるのでしょうか。

queueの更新はrerenderReducerで行われます。queueの構造を思い出しながら読んでください。

function rerenderReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;

  queue.lastRenderedReducer = reducer;

  // This is a re-render. Apply the new render phase updates to the previous
  // work-in-progress hook.
  const dispatch: Dispatch<A> = (queue.dispatch: any);

  // queue.pendingは一番最後の更新、pending.nextは最初の更新が入っていた
  const lastRenderPhaseUpdate = queue.pending;
  // hooks.memoizedStateは現在DOMに反映されているhookの値
  let newState = hook.memoizedState;
  if (lastRenderPhaseUpdate !== null) {
    // The queue doesn't persist past this render pass.
    queue.pending = null;

    const firstRenderPhaseUpdate = lastRenderPhaseUpdate.next;
    let update = firstRenderPhaseUpdate;
      

    do {
      // Process this render phase update.
      const action = update.action;
      // setState(action)に等しい
      newState = reducer(newState, action);
      // 次のupdateに行き、循環リストが一周するまでnewStateを更新し続ける。
      update = update.next;
    } while (update !== firstRenderPhaseUpdate);

    // Mark that the fiber performed work, but only if the new state is
    // different from the current state.
    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
    }

    hook.memoizedState = newState;
    // Don't persist the state accumulated from the render phase updates to
    // the base state unless the queue is empty.
    if (hook.baseQueue === null) {
      hook.baseState = newState;
    }

    queue.lastRenderedState = newState;
  }
  return [newState, dispatch];
}

stateの更新だけに限って話すと、以下が概要です。

do {
  newState = basicStateReducer(newState, update.action);
  update = update.next;
}

function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action;
}

つまり、setStateで入れられたqueueはrerenderReducerによって順番に適応され、新しい値がhook.memoizedStateに代入されます。useStateで返されるstateはhook.memoizedStateを参照しているので、コンポーネントが再計算されたときにこの値が取得できるわけです。

この再計算がどこで行われるかというと、初日の記事で読んだrenderWithHooksです。明日はrenderWithHooksを中心にuseStateの総ざらいをしたいと思います。