Deno Freshでひっかかったポイントの中身を見てみる

ジョブカン事業部のアドベントカレンダー7日目です。

https://qiita.com/advent-calendar/2024/jobcan

---

普段からごりごりアプリを書いている訳ではありませんが、TypeScript・Denoエコシステムの充実した機能と、コードを統一できるという点を鑑みて、サーバーもクライアントも[Deno](https://deno.com)で書くのが割と幸せなんじゃないかという説が私のなかで浮上しています[^1]。
[^1]: 最近(特にv2になってから)はNodeっぽくなってきていて不穏ですが……

そんなDenoは公式から[Fresh](https://fresh.deno.dev)というフレームワークが出ています。フロントエンドでは[Preact](https://preactjs.com)が使われています。

導入などは他の記事に譲りますが、最近趣味で作り始めたアプリをこれで書いていて、ちょっと時間を使ったポイントがあったので、対処方法を書いておいて、ついでにコードを眺めて何が起きているのかを見てみました。

## オブジェクトのSignalが更新されない
これはPreactの話にはなりますが、SignalというStateの上位互換みたいなのがいます。
Contextに近いですが、それ単体を受け渡すだけでは更新時の再描画の対象にならない変数です。

```tsx
function GrandParent() {
  const [state, setState] = useState(0);
  const signal = useSignal(0);
  return (
    <Parent state={state} setState={setState} signal={signal} />
  );
}

function Parent({ state, setState, signal }) {
  return (
    <Child state={state} setState={setState} signal={signal} />
  );
}

function Child({ state, setState, signal }) {
  return (
    <>
      <input type="button" onClick={() => setState(state + 1)} value="Increment state" />
      <span>State: {state}</span>
      <input type="button" onClick={() => { signal.value = signal.value + 1}} value="Increment signal" />
      <span>Signal.value: {signal.value}</span>
    </>
  );
}
```

"Increment state"のボタンを押すと`state`が変更され、このとき`<Parent>`も再描画されますが、"Increment signal"のボタンを押して`signal`が更新されたときは、`state`の時とは違って`<Parent>`は再描画の対象になりません。
これによってページ全体の描画のコストを抑えることができます。

signalはコード例の通り内部の値を`signal.value`で取得できます。
また、`signal.value = data`のように、`.value`への代入を行うことによって内部の値を更新することができます。Stateでいうと`setState()`に相当します。

さて、オブジェクト等ではこの取得した値のプロパティ等を変更することもできますが、変更したあとの値をそのまま`signal.value`に代入しても再描画が行われませんでした(内部の値は更新されていました)。
著者がReactを学んだときに、「stateの値は直接更新しない方がいい」というのを見たことがあったので[^2]、`structuredClone`を使ってコピーしてみるとちゃんと再描画が行われるようになりました。
実際には`Array.from()`や`Object.assign()`などのシャローコピーでも問題ありません。
[^2]: あったので横着だという自覚はありました

```typescript: これはだめ
const obj = signal.value;
obj.foo = 'bar';
signal.value = obj;
```

```typescript: こうするとよい
const obj = Object.assign({}, signal.value)
obj.foo = 'bar';
signal.value = obj;
```

直接再代入だとダメでシャローコピーすれば良い理由を見てみます。
signalのソースコードは https://github.com/preactjs/signals で公開されています。
`packages`ディレクトリにいくつかのパッケージがあり、`preact`には`useSignal`などが定義されています。いずれにせよ中では`core`の機能を使っています。
```
signals
├── packages
│   ├── core
│   ├── preact
│   ├── react
│   └── react-transform  
└── ...
```

`signal.value`に代入を行うときの処理は`core/src/index.ts`内で、`value`プロパティのsetterとして定義されています。

```typescript:packages/core/src/index.ts
Object.defineProperty(Signal.prototype, "value", {
	get(this: Signal) { /* 省略 */ },
	set(this: Signal, value) {
		if (value !== this._value) {
			if (batchIteration > 100) {
				throw new Error("Cycle detected");
			}

			this._value = value;
			this._version++;
			globalVersion++;

			/**@__INLINE__*/ startBatch();
			try {
				for (
					let node = this._targets;
					node !== undefined;
					node = node._nextTarget
				) {
					node._target._notify();
				}
			} finally {
				endBatch();
			}
		}
	},
});
```

今回の理由を探るだけであれば細かく読む必要はなく、`set`の最初の1行だけで十分です。
`_value`はsignalが内部で持っている状態、`value`は代入された値ですが、この値が異なるときのみ中の処理(再描画など)を行うようになっています。
同じオブジェクトを再代入してもここが`true`になってしまい、中の処理に入らないので再描画が発生しないのでした。

## Partialsを使っているのにリンクをクリックするとリロードが入る

Partialsはv1.5で追加された、クライアントルーティングを実現する機能です。
要するに`react-router-dom`や`next/link`を使ったときみたいな遷移になります。
画面が一瞬真っ白になることがないので目に優しくなります。

Freshの場合はこれらとは違ってクライアントで完結しておらず、リンクをクリックした際に当該ページのHTMLを取得し、`<Partial>`タグ内のDOMのみを置換することによって実現します。
毎回通信をするのでデメリットもありますが、各ページへの初回アクセス時にやりとりするデータが減るといったメリットもあります。

使い方は簡単で、`f-client-nav`属性をつけた要素の中に、

```tsx:routes/_layout.tsx
export default function Layout({ Component }) {
  return (
    <div f-client-nav>
      <nav>
        <a href="/a">Link to A</a>
        <a href="/b">Link to B</b>
      </nav>
      <Component />
    </div>
  );
}
```

```tsx:routes/a.tsx
import { Partial } from "$fresh/runtime.ts";
export default function A() {
  return (
    <Partial name="main">
      <span>This is A</span>
    </Partial>
  );
}
```
```tsx:routes/b.tsx
import { Partial } from "$fresh/runtime.ts";
export default function B() {
  return (
    <Partial name="main">
      <span>This is B</span>
    </Partial>
  );
}
```

こうすると`<a>`タグをクリックしたとき、当該ページにまるごと遷移するのではなく、遷移先のページのDOMを取得して、`name`が一致する`<Partial>`タグの中身だけが書き換えられます。
1つのページに複数の異なる`name`を持つ`<Partial>`タグを含むこともできます。

このとき、`<a>`タグを`f-client-nav`な要素の外に置いてしまうと、リンクをクリックしたときに普通に遷移してしまいます。
つまり、

```tsx:routes/_layout.tsx
export default function Layout({ Component }) {
  return (
    <>
      <nav>
        <a href="/a">Link to A</a>
        <a href="/b">Link to B</b>
      </nav>
      <div f-client-nav>
        <Component />
      </div>
    </>
  );
}
```

のように書くとうまく働きません。

各`<a>`タグが`f-client-nav`な要素の子孫要素にするとスムーズに遷移するようになります。
特にファイルを分けている場合はちゃんと中に来るように注意しなければなりません。

こうなる理由を見てみます。
Freshのソースコード (https://github.com/denoland/fresh) 内で`f-client-nav`と検索をかけると`src/constants.ts`にそれっぽい定数が定義されており、
```typescript:src/constants.ts
export const CLIENT_NAV_ATTR = "f-client-nav";
```
この定数名で検索をかけると`src/runtime/entrypoints/main.ts`にそれっぽい処理が書いてあるのが見つかります。

```typescript:src/runtime/entrypoints/main.ts
function checkClientNavEnabled(el: HTMLElement | null) {
  if (el === null) {
    return document.querySelector(`[${CLIENT_NAV_ATTR}="true"]`) !== null;
  }

  const setting = el.closest(`[${CLIENT_NAV_ATTR}]`);
  if (setting === null) return false;
  return setting.getAttribute(CLIENT_NAV_ATTR) === "true";
}
```
雰囲気で読んでみると、指定された要素の最も近い親で`f-client-nav`が設定されている要素を取得し、それが見つからないか、`false`が指定されている場合に`false`を返す関数のようです。

これを呼んでいるのが同じファイルのこの部分です。

```typescript:src/runtime/entrypoints/main.ts
document.addEventListener("click", async (e) => {
  let el = e.target;
  if (el && (el instanceof HTMLElement || el instanceof SVGElement)) {
    const originalEl = el;

    // Check if we clicked inside an anchor link
    if (el.nodeName !== "A") {
      el = el.closest("a");
    }
    if (el === null) {
      el = originalEl.closest("button");
    }

    if (
      // Check that we're still dealing with an anchor tag
      el && el instanceof HTMLAnchorElement &&
      // Check if it's an internal link
      el.href && (!el.target || el.target === "_self") &&
      el.origin === location.origin &&
      // Check if it was a left click and not a right click
      e.button === 0 &&
      // Check that the user doesn't press a key combo to open the
      // link in a new tab or something
      !(e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.button) &&
      // Check that the event isn't aborted already
      !e.defaultPrevented
    ) {
      const partial = el.getAttribute(PARTIAL_ATTR);

      // Check if the user opted out of client side navigation or if
      // we're doing a fragment navigation.
      if (
        el.getAttribute("href")?.startsWith("#") ||
        !checkClientNavEnabled(el)
      ) {
        return;
      }

      // deno-lint-ignore no-explicit-any
      const indicator = (el as any)._freshIndicator;
      if (indicator !== undefined) {
        indicator.value = true;
      }

      e.preventDefault();

      const nextUrl = new URL(el.href);
      try {
        maybeUpdateHistory(nextUrl);

        const partialUrl = new URL(
          partial ? partial : nextUrl.href,
          location.href,
        );
        await fetchPartials(partialUrl);
        updateLinks(nextUrl);
        scrollTo({ left: 0, top: 0, behavior: "instant" });
      } finally {
        if (indicator !== undefined) {
          indicator.value = false;
        }
      }
    } else if (
      el && el instanceof HTMLButtonElement &&
      (el.type !== "submit" || el.form === null)
    ) {
      const partial = el.getAttribute(PARTIAL_ATTR);

      // Check if the user opted out of client side navigation.
      if (partial === null || !checkClientNavEnabled(el)) {
        return;
      }

      const partialUrl = new URL(
        partial,
        location.href,
      );
      await fetchPartials(partialUrl);
    }
  }
});


```

`document.addEventListener()`でドキュメント全体に`click`イベントのコールバックを設定していました(びっくり)。
ごちゃごちゃしていますが、基本的には`<a>`タグと`<button>`タグのクリックにあわせて上で書いたような処理をしています。

そして、
> // Check if the user opted out of client side navigation or if
  // we're doing a fragment navigation.

の下にあるif文で`checkClientNavEnabled`が使われており、`false`であれば`return`、つまり特別な処理を何もしないのだとわかります。

したがって`<a>`タグが`f-client-nav`の子要素でなければならなかった訳です。
一方で、`<Partial>`タグは`f-client-nav`の子要素でなくても問題なさそうなことがわかります。

## esm.sh経由でReactのライブラリを使いたい
Preactは基本的にReactと同じように使うことができますが、内部のAPIが微妙に異なっており、ライブラリを使おうとするとエラーが出ました。

これを回避するために、Preactは`preact/compat`という互換性確保のためのモジュールを提供してくれています。
import mapで、`react`と`react-dom`と`@react/types`がこれを向くようにしてやります。

```json:deno.json
{
  "imports": {
    "react": "https://esm.sh/preact/compat",
    "react-dom": "https://esm.sh/preact/compat",
    "@react/types": "https://esm.sh/preact/compat"
  }
}
```

ただ、これだけだと不十分で、esm.shはパッケージが他のパッケージを参照している場合、自動でそちらもimportするようになっています。
したがって利用するライブラリが`react`をインポートしていると、Reactを読み込んでしまいます。

これにはesm.shのURLに、`external`クエリでパッケージ名を指定してやると起きなくなります。
つまり、React用のライブラリを利用するときに、

```json:deno.json
{
  "imports": {
    "library": "https://esm.sh/library?external=react,react-dom,@types/react
  }
}
```

と書くことで、ライブラリが`import "react"`などとしている箇所でもReactではなく`preact/compat`を通してPreactが使われるようになります。

例えば今回作っているアプリでは`@reach/combobox`を使ってみていますが、`external`の有無でesm.shが返すコードを見比べてみます。

```typescript:https://esm.sh/@reach/combobox@0.18.0
/* esm.sh - @reach/combobox@0.18.0 */
import "/stable/react@17.0.2/es2022/react.mjs";
import "/v135/@reach/utils@0.18.0/es2022/utils.mjs";
import "/v135/@reach/descendants@0.18.0/es2022/descendants.mjs";
import "/v135/@reach/auto-id@0.18.0/es2022/auto-id.mjs";
import "/v135/@reach/popover@0.18.0/es2022/popover.mjs";
export * from "/v135/@reach/combobox@0.18.0/es2022/combobox.mjs";
```

```typescript:https://esm.sh/@reach/combobox@0.18.0?external=react
/* esm.sh - @reach/combobox@0.18.0 */
import "/v135/@reach/utils@0.18.0/X-ZS9yZWFjdA/es2022/utils.mjs";
import "/v135/@reach/descendants@0.18.0/X-ZS9yZWFjdA/es2022/descendants.mjs";
import "/v135/@reach/auto-id@0.18.0/X-ZS9yZWFjdA/es2022/auto-id.mjs";
import "/v135/@reach/popover@0.18.0/X-ZS9yZWFjdA/es2022/popover.mjs";
export * from "/v135/@reach/combobox@0.18.0/X-ZS9yZWFjdA/es2022/combobox.mjs";
```

2行目が消えています。
この状態であれば`utils.mjs`などが`react`をインポートしても、import mapで登録した`preact/compat`がインポートされます。

## おわりに

あまりまとまりのない記事だった気がしますが、ライブラリまわりでうまくいかないときはドキュメントはもちろん、コードも読もう!ということで締めたいと思います。楽しいですし。

ちなみにFreshはv2の開発が進んでいて、[JSR](https://jsr.io)にはbeta版として上がっています。
この記事が出る前にリリースされたらちょっと悲しくなりそうでしたが大丈夫でした。

---

DONUTSでは一緒に働くメンバーを募集しています。
リンクから採用ページを覗いてみてください。

https://www.donuts.ne.jp/recruit/

https://recruit.jobcan.jp/donuts/show/B001/1396297