今の会社は期初が8月なんです。なので今が期末なんですが、色々ありすぎてもう月末になってました。

最近cycle.js を使って趣味プログラムを作ってみているのですが、その中で色々試していたらとてつもなくハマったので、顛末含めて書いておこうかと思います。

Cycle.js

以前、solid.jsについての話をちょろっと書きましたが、それよりも古株なリアクティブフレームワークです。

という感じです。以前はRxJS(うっ、頭が・・・)を前提としていたようですが、現在はxstreamというストリームライブラリをデフォルトにしつつ、RxJS/mostというストリームライブラリで利用できるようになっています。

ここが楽しいよCycle.js

ひょんなことで知ったcycle.jsですが、触ってみて色々楽しいです。

さて、そんな楽しいCycle.jsですが、全然関係無いところでドハマリしまくりました。

ドハマリその1。d3.jsに推移的に依存しているやつが動かない

最近のd3.jsなんですが、 ESM用のファイルしか提供していません 。これはお仕事で使っているAngularとかでも発生していてマジで悲しいところなんですが・・・。

今回、テストランナーとして(一部で)話題になっている uvu を利用しています。uvuはNative ESMでも利用できるようになっていて、作者としてもそっちを推奨しているっぽいです。

・・・が、話には聞いていたのですが、このNative ESMがとにかく鬼門でした。特にJSだけでやっているならまだしも、TypeScript + pathsのエイリアスを利用していたので、さらにドハマリしました。

最終的には自作loader + esbuild-registerでテストは動くようになったのですが、uvuの特徴である爆速実行の時間<<<ビルドの時間、となってしまっているので、まだ改善したいところ。

ts-node/registerを使ってみる

とりあえず node -r とかで利用しようと検索すると、ts-nodeがまっさきに出てきます。なのでまずはこれを利用してみることにしました。

→が、動かないです。色々把握した今ならそりゃそうだろうってなりますが・・・。

esbuild-registerを使ってみる

次に、uvuの作者が使っているというesbuild-registerを使ってみることにしました。

→が、Native ESMでは動かないです。これはそもそもesbuild-register側がESMの解決に対応していないため、っぽいです。

tsmを使ってみる

さらに、uvuの作者が作ったtsmというライブラリを利用してみました。

→動く。が、遅い・・・。という感じになってました。というかpathsを上手く解決できなかった。

(採用)自作loader + esbuild-register

https://kimuson.dev/blog/typescript/ts_node_esm_paths/

課題として、 pathsが解決できない ということと、 TypeScriptをimportできない ということを解消するには、esmのloaderをなんとかするか、esbuild-registerを使えばとりあえずいける、ということまではわかりました。(かなり怪しいですが)

なので、最終的には↑を参考にして、tsx/tsの場合はcommonjsとして読み込むように強制しつつ、esbuild-registerでbundleしてもらう、という道でなんとかなりました。

ただ、esbuild-registerだとbundleを作成するため、d3.jsの依存があるとd3.jsも含めてbundleしてしまっているようで、かなり(tscより速いとはいえ)時間がかかります。bundleしないはずのswcを使ったりしたらいいのかもしれませんが、まだこの構成にしてから試せてないです。

import fs from 'fs';
import path from 'path'
import typescript from 'typescript'
import { createMatchPath } from 'tsconfig-paths'

const { readConfigFile, parseJsonConfigFileContent, sys } = typescript

const __dirname = path.dirname(new URL(import.meta.url).pathname)

const configFile = readConfigFile('./tsconfig.json', sys.readFile)
if (typeof configFile.error !== 'undefined') {
  throw new Error(`Failed to load tsconfig: ${configFile.error}`)
}

const { options } = parseJsonConfigFileContent(
  configFile.config,
  {
    fileExists: sys.fileExists,
    readFile: sys.readFile,
    readDirectory: sys.readDirectory,
    useCaseSensitiveFileNames: true,
  },
  __dirname
)

const matchPath = createMatchPath(options.baseUrl, options.paths)

const extensionsRegex = /\.ts$|\.tsx$/;

export async function load(url, context, defaultLoad) {
  if (extensionsRegex.test(url)) {
    const { source } = await defaultLoad(url, { format: 'module' });
    return {
      format: 'commonjs',
      source: source,
    };
  }
  // let Node.js handle all other URLs
  return defaultLoad(url, context, defaultLoad);
}

export async function resolve(specifier, context, defaultResolve) {
  const matchedSpecifier = matchPath(specifier)
  return defaultResolve(
    matchedSpecifier ? `${matchedSpecifier}` : specifier,
    context,
    defaultResolve
  )
}

ドハマリその2。jsdomでcycle.jsが動かない

だいたい丸2日溶かしました。Cycle.jsが提供しているDOM driverという機構は、eventをstreamとして扱う機能があるのですが、何をどうやってもこのeventがjsdom上だと動きませんでした・・・。

軽く見た感じだと、EventDelegatorというのが刺さっているので、jsdom側でfireできれば基本的には問題ないはず・・・だったんですが、どうにも動かせず

今考えると、bubbleされてなかったんじゃないかとかは色々ありますが

最終的には、同じくCycle.jsが提供している mockDOMSource と、@cycle/time、そして snabbdom-selectorを利用して書くことにしました。最終的にpromiseでラップしたら普通に動くし、visual testingしているわけでもないから十分かな・・・という。

test("allow user to submit if all value is valid", async () => {
  await new Promise<void>(async (resolve, rej) => {
    // Arrange
    const Time = mockTimeSource();
    const domain$ = Time.diagram("--x------|", { x: { target: { value: "domain" } } });
    const cred$ = Time.diagram("---x-----|", { x: { target: { value: "cred" } } });
    const submit$ = Time.diagram("----x----|", { x: { target: {} } });
    const dom = mockDOMSource({
      ".user-configuration__user-domain": {
        input: domain$,
      },
      ".user-configuration__credential": {
        input: cred$,
      },
      ".user-configuration__form": {
        submit: submit$,
      },
    });

    // Act
    const sinks = UserConfigurationDialog({ DOM: dom as any });

    const actual$ = sinks.DOM.map((vtree) => {
      return select(".user-configuration__submitter", vtree)[0].data?.attrs?.disabled;
    });
    const expected$ = Time.diagram("a-ab-----|", { a: true, b: false });

    // Assert
    Time.assertEqual(actual$, expected$);

    Time.run((e) => {
      if (e) rej(e);
      else resolve();
    });
  });
});

例としては↑のようになりました。結構diagramの長さとかにも影響するので、長さを揃えないと大分使いづらくもあるのですが、まぁそれはそれで・・・という感じです。

ほぼpureな状態でテストできるのも、Cycle.jsの魅力だと思うことにして、browser testingはまた別で考えよう・・・となりました。

d3.jsにハマる

後はd3.jsに色んな意味でハマりました。enter/exitとかを把握するのがとても辛い・・・。また、d3.jsだけの世界を築いているため、仮想DOMとの相性が悪いとかも色々ありますが、まぁここはdriverに分離できる、ということに気付き、結構綺麗に分離できました。個人的にはhookよりよほど仕組みとしてわかりやすいです。

ESMはマジ難しい

とりあえずドハマリしたのはESM周辺でした。TypeScriptとESMの相性の悪さは話には聞いていましたが、自分でぶつかると意味合いの理解もひとしおですね・・・。

色々な方の情報を参考にさせてもらいましたが、もし同じようなことをしようとしている人の参考になれば。