仕組み
@k8ordo/stateがどう動いているかを説明します。使い方を覚えるのに必要な内容ではありませんが、なぜそう書くのかが分かると、迷ったときに判断しやすくなります。
このページの内容
定義は純粋で、ストアはブラウザにある
定義は、スキーマと純粋な関数だけのデータです。メモリの状態なら、スキーマの代わりに初期値を持ちます。中にストアを持たないので、Server Componentがimportしても、サーバーに状態は生まれません。
shared definePageState / defineLocalState / defineSessionState
defineCookieState / defineMemoryState
↓ import ↓ import
server parseUrl(searchParams) client useAppState(def)
parseCookies(cookies) → [state, update]
href(path, values)ブラウザのストアは、最初にuseAppStateが呼ばれたときに作られ、定義の種類と文字列のキーで登録されます。HMRで定義のモジュールが評価し直されて新しいオブジェクトになっても、キーが同じなら同じ状態につながるのはそのためです。
モジュールを読み込んだだけでは、navigationやlocalStorage、document.cookieには触れません。そのため定義は、サーバーでもテストでも、そのままimportできます。
同じ種類の定義が同じキーを使っても、実行時には知らせません。HMRで評価し直せば、同じキーがもう一度登録されるのが正しい動きだからです。そこで警告を出すと、編集するたびに誤った警告が出てしまいます。
Providerが無い理由
URLと履歴エントリ、Web Storage、Cookieは、どれもブラウザに1つずつしかありません。ストアはそれをそのまま映しているだけなので、Providerで範囲を区切る理由がありません。
その代わり、テストではストアがテストをまたいで残ります。テストのたびにresetStateRegistry()を呼ぶのは、そのためです。
ルーターに求めること
ルーターの性質に左右される操作は2つだけで、ほかはどのルーターの下でも動きます。
hrefとsearchで作ったリンク、GETフォーム:何も要りません。クリックや送信は、ルーターかブラウザが処理します。- URLを変えない
update():何も要りません。entryやWeb Storage、Cookie、メモリの書き込みは、遷移を伴わないからです。 - URLを変える
update():Navigation APIの遷移を受け止めるルーターが要ります。 - サーバーで
urlを読むこと:ページにクエリを渡すルーターが要ります。@k8ordo/serverなら、searchのexportです。
URLを変えるupdate()はnavigation.navigate()を呼びます。@k8ordo/routerの下では、@k8ordo/staticや@k8ordo/serverのページも含めて、pathnameが変わらない遷移はページの切り替えではなく状態の変更です。ルーターは何も読み込まずに受け止め、何も再マウントせず、スクロールもフォーカスも動かしません。update()がすでに新しい値を描いているので、finishedはその遷移が落ち着いた時点で解決します。
ただし、@k8ordo/serverでsearchをexportしたページのクエリが変わったときは、ページがその場で読み込み直されます。finishedは、それが表示されるまで待ちます。pathnameはルーターが受け持ち、?から後ろはこのパッケージが受け持つという分け方です。
Navigation APIの遷移を受け止めないルーター、たとえば今のNext.jsでは、同じ呼び出しがドキュメント全体の読み込みになります。そこではリンクとGETフォームでURLを変えます。History APIで代わりに書く仕組みは、あえて持っていません。
壊れた値は、そのフィールドだけを既定値に戻す
境界を越えて戻ってきた値は、信頼できる状態ではなく入力として扱います。読むときは、次の順に進みます。
- スキーマ全体で読みます。通れば、それで終わりです。
- 通らなければ、フィールドを1つずつスキーマに通し、通ったものだけを残します。
- 残したフィールドで、もう一度スキーマ全体を通します。オブジェクト全体の
.refine()は、ここで走ります。 - それでも通らなければ、すべてを既定値に戻します。すべてが既定値の状態は、定義のときに通ることを確かめてあります。
2回目に全体を通すときに渡すのは、各フィールドの出力ではなく、届いたときの入力です。z.stringbool()のように、出力をもう一度入力にできないフィールドがあるからです。
update()で書いたurlの値は、いったんクエリ文字列に書いてから読み直します。訪問者のURLと同じ道を通すことで、z.stringbool()のような一方向の綴りも正しく動きます。一方でentryとWeb Storage、Cookieは、型の付いた値をそのままスキーマに渡します。そのため、そこではスキーマが自分の出力を受け付けなければなりません。
変わったキーだけに知らせる
定義は、キーの集まりを決めています。スキーマのキーか、メモリなら初期値のキーです。そのため、何が変わったかをキーごとに正確に決められます。
比べるのはフィールドごとで、配列とプレーンなオブジェクトは中身を、それ以外はObject.isで比べます。DateやMap、クラスのインスタンスは参照で比べるので、同じ時刻を指す新しいDateも変わったものとして扱います。
変わらなかったフィールドは、前と同じ参照を保ちます。キーを指定した購読には、指定したキーが変わったときだけ知らせます。
分け合う場所は分け合ったまま
URLとエントリの状態は、1つのページの状態だけのものではありません。ほかの定義やルーター、計測用のパラメータも同じ場所を使います。
ページの状態が書き換えるのは、自分のパラメータと、エントリの状態の中の自分の名前空間だけです。ほかのものは、書き込みのたびにそのまま運ばれます。
書き込む値は、描画に出した値ではなく、その時点のブラウザの値にバッチの変更を重ねて作ります。書き込む前にほかのタブやほかの定義が書いた値を、巻き戻さないためです。また、まだ書き込んでいないバッチがある間にほかの書き込みが届いても、バッチの変更はその上に重ねたまま残ります。
Cookieの書き込みは非同期なので、1つずつ順に行います。前の書き込みが入ってから次の書き込みが今の値を読むので、2つのバッチが互いの値を古い値で上書きすることはありません。