今日で US に move してきてちょうど一か月になる。NC に着いて日常が落ち着いた後、周辺の地域と近場の観光地もほぼ回り終えて、人は暇になった。ただ、あいにく私は暇でいられる人間ではなく、暇になるとすぐ自分で何かやることを探し始める ブログを拾い直すのも一つに数えていいよね。でも運悪く US ではこちらの物流があまり便利ではないので、基板は当面描けない。となると戻ってきて軽くソフトウェアでも書くしかないし、ついでに数か月前に投げ出した穴を拾って埋めようかな?
ただ、他人にツッコミを入れ始める前に、まず seam & SeamJS まわりの知識を少し補っておいたほうがいいかもしれない(x これって一体何なの なにしろ前回の記事から数ヶ月空いているので、たぶん皆さんきれいさっぱり忘れているだろうし、Github の README.md を直すのも今のところ面倒くさい
プロトコルとしてのレンダリング
ユーザーインターフェースは実行されるのではなく、記述されるべきだ。
では簡単に言うと、Seam1 はまず何よりプロトコルであって、また新しい SSR runtime ではない。つまり build-time に slot 付きの HTML skeleton を一枚吐き出し、runtime-time / request-time はその slot に値を詰めるだけで、if / each / match も同じく skeleton に固定で書き込まれたプロトコルノードだ。だからリクエストが来た時点で UI コンポーネントは一つも実行されておらず、サーバーがやるのはという一つのことだけ。なら当然 Node や Bun である必要もなく、Rust / Go みたいなバックエンドが HTML を一枚埋めたっていいじゃないか、何が難しいって言うんだ?! まあ実はその後も JS Runtime を完全には消しきれてなくて、要るのをやめられるけど一部の制限を受け入れられるならという前提付きだ とはいえどのみち、全体のパフォーマンス向上は間違いなく指数関数的で、大半の SSR 効果を得るのに runtime-time / request-time で UI render を走らせる必要がもうないのだから...
リクエスト時モデル? 2
今年の後半あたりから、このサイトを TanStack Start React から完全に port して SvelteKit Svelte へ全面移行した。移行を終えてみると、確かに良いものだった。ssr のパフォーマンスオーバーヘッドはほぼ一桁下がり、ハイドレーションのコストは非常に小さく、動作は快適で、フレームワークのコンパイル志向の哲学も素晴らしく、とても軽量だ。ただ Kit の部分には、どうしても物足りなさを感じるところがあった。その大半はプラグインで解決できるのだが なにしろ Vite を組み合わせたものなので、それでもどうしても手を出せない部分が残る。とはいえ、これはおそらく私自身の問題で、seam が変えたい箇所は既存のどの meta フレームワークとも本質的に衝突するからだろう。要するに私は、彼らの実行モデル(リクエストごとに render 関数を走らせる)、データ境界(load と UI が混ざっている)、バックエンド能力(JS ランタイムに縛られている)に挑んでいるのだ。
擬似 SSR 3
ここで必ず誰かが聞くだろう。それは Astro / Qwik / Marko と同じじゃないのか、と。、言語の形では Seam は Marko に最も近く、デフォルトで JS が少ない点では Astro に、replay hydrate をしない点では Qwik に最も近い。だが、彼らの request-time は依然として UI プログラム island を走らせている。各自の render、Qwik のあの SSR、Marko がコンパイルした JS テンプレート関数、どれもリクエストごとに renderer の塊を実行しているのだ。
だから {#each} の千行分も、本質的には私はまだ HTML を組み立てているわけで、ドキュメント4を生成しないのではなく、違いは私が組み立てるのはすでに自己完結したプロトコルであって、コンポーネント Tree を再生成するのではないという点にある。Seam が求めるのは AST → IR → slot 付きの HTML で、リクエスト時にはプロトコルの解釈と、動的データ領域の injection を足すだけなので、UI render の部分の関数をもう走らせる必要がない。だから任意のバックエンドがすでに residual 化されたその最後の一層を埋められるし、vendor lock in JS5の問題も解決する。つまり実は私と上記の既存のものは、大きな方向性が同じ線上にはない(x)
Adopt
Seam はハイドレーションを行うが、従来のやり方でも Qwik のやり方でもない。従来のハイドレーションはクライアントがもう一度 render して DOM と突き合わせる。Qwik は HTML + コンポーネント状態を SSR し、クライアントは resume するだけで replay しない。しかし Seam の request-time では UI renderer すら実際には走らせる必要がない。ハイドレーション用の骨格、ファーストビュー、hydrate のデータはすべて同じ一回の injection から来る。クライアントが受け取るのはすでに正しい DOM + 同じデータであり、コンパイル時の IR に従ってイベントと client state を取り付ける adopt。これは replay ではなく、当然ながらコンポーネントツリーを resume することでもない。
だからこそ「両側でそれぞれ render してから突き合わせる」類の mismatch6 は、デフォルトから不正な状態へと変わった。実装が 100% だと主張しているわけではなく、この種のずれはモデルの中で不正だということだ。出てきたなら、それは私の compiler / injector 実装にある bug であり、fix できる。典型的な例は (骨格が HTML parser を通っていない、エスケープが合っていない、IR と骨格が同期していない) だ。
もちろん skeleton 自身は parse-stable7 でなければならない。table に comment を無秩序に詰め込むようなものは実装の処理不足であって、私のモデルのせいにしないでほしい。Build-time が計算するのはハイドレーションの対応関係(どれが静的で、どれが slot で、何を束縛するか)であり、データを前もって固めてしまうことではない。ただし、この slot のデータ型は固定してしまう。データの値そのものは request-time に自由に差し替えられる。ただ HTML と payload が同一のものでなければならない、というだけだ。
同じグラフ?
Astro はたしかにファーストビューもドキュメントであり、その後も ClientRouter できるが、違いはその動的部分が依然として islands であり、それぞれが render する点だ。ClientRouter は本質的に document morph に近く、ハイドレーション後もまだ生きている同じ一枚のコンポーネントグラフではない。だから Astro の技術スタックはそもそもこれに向いていない。Astro 自体が悪いわけではなく、ただそういう使い方を想定して設計されていないだけだ。もちろん View Transitions API8 が使えると言うこともできるが、それなら聞きたい、View Transitions API は飯の種になるのか、ページをまたいで Motion アニメーション9を重ねられるのか... 少し脱線した。
どのみち水和前には誰も動けないのだから、初回フレーム10 の JS が立ち上がる前にアニメーションがないのは、もともと誰のせいでもない。Seam が保証するのは injection が正しいこと、document が正しいこと、client のエントリが正しいことだけで、ここで本当の制約になるのは水和後だ: client ナビゲーションは、まだ生きているそのグラフにデータを流し込んで行う (layout は持続する) のであって、もう一度 injection を通して document を差し替えるのではない。要するに Seam が水和後に引き継ぐのは同じ一枚の静的コンポーネントグラフだ。
実はここには Marko 6 もあって、すでに私の考えにかなり近い (テンプレート AST、コンパイル時の細粒度更新、replay ではなく resume)。しかし Marko と比べて技術的にまだ線を引ける点は主に二つで、一つは request-time がまだ JS のコンパイル成果物を動かしていて、チェーン全体が JS runtime に縛られていること。もう一つは作者の使う言語が Svelte ではなく Marko であること。26年にフロントエンドを語るとき、ほとんどの人が主流の UI Stack として考えるのはきっと React, Vue, Svelte, Solid だけだろうし、私が言わなければ Marko が何かすら知らない人も多いかもしれない。だから Svelte の上に築くというのは、「原理を一式コピーして backend を足す」というより、誰かが書き、誰かが使い、誰かが絶えず bug を直し磨き続けている UI を借りて使う、と私は説明したい。どれも皆がよく知っている内容だ。
それから、Qwik の「ハイドレーションしない」が省いているのはクライアント側の replay であって、サーバー側のあの一回の SSR11(?) は省けていない。だから残念ながら、今のところ私を満足させてくれるフレームワークも、私が足し算をする余地を持てるだけの API を与えてくれるフレームワークもひとつもなく、それが数か月前に私が SeamJS というものを作りに行った理由でもある。というわけで今日は、あらためてその roadmap を練り直しに来た、という話だ。
今の私は「Svelte: for building, not frameworking.12」に深く共感している
React
まず React から。Vercel Next.js との商業的な結びつきや、独自の roadmap を持っていることはさておき、何よりまず CVE が飛び交いすぎる。それだけで、新しい脆弱性が report & release されていないか、四六時中 Social Media を見張る羽目になる。他の Router は基本的に使えない状態、特に Remix は13。ただ 2026年 には非常に特別な選択肢がある。TanStack Start だ。私はかなり早くから TanStack Router を使っていて、命名スタイルとデータ構造があまり美しくない14点を除けば、機能性の面では唯一認められる選択肢だと言える。新しいといえば新しいが、Next.js 16 の next-server プロセスが今もって解決できていない、コールドスタート時の初回アクセス速度の問題を解決している。Next.js は実際、昔からわけもなくこの引っかかりがあり、サーバー性能が足りないのではなく、起動時の Load が完了していないか、runtime がまだ何かをコンパイルしているかのどちらかだと私は見ている。しかもこの構造は、Cloudflare Worker のような多地域インスタンスが直近に起動されるという原則とは相性が悪い。Vercel のような従来型の Node 長時間プロセス15だけがこの問題を食らわない(?) 少し話が逸れた。Next.js についてはいずれ丸ごと一本、愚痴を書ける。それでも TanStack16 は、商用利用に十分耐える成熟度に達していると本当に思う。少なくとも私はあるプロジェクトの販売サイトで使った。とはいえ、これは二つしかない選択肢の一つにすぎない(x)
Vue
Vue については、個人的に Vue 2 から Vue 3 へ移行した際に、構文などの仕組みにあまり賛同できなくなった。私の心の中の Vue は永遠に Vue 2 だ。Vue 3 に素晴らしい feature が数多くあることは否定できないが、Vue 2 と比べると、そのコアは私にとって変わってしまったように感じる。しかも大きく変わった。また商業的な観点から考えると、Vue はますます meta 層に完全に縛り付けられ、エコシステムに支配されているのではないか?その状況は Svelte と非常によく似ていて、どちらもオープンソースではあるが、第三者が立ち上がって第二の実装を作ってくれることは本当にほぼありえない。これこそ TanStack Start の参入による再編が Next.js に与えた打撃が本当に大きかった理由であり、Tanner Linsley は称賛されるべきだ。もう一つ、Vue の無視できない問題がある。初期の Vue は確かに革新だったが、どんな UI Stack もエコシステムが育った後にはエコシステムの問題にぶつかる。つまり互換性のために一定の妥協が生まれるということ? 特にこの問題は React で本当に顕著で、今の Vue も例外ではない。こうした内容17が現れ始めたとき、私は大抵それを Slop と見なす。とはいえ悪いとも言えない。Nuxt が次の「エンタープライズ級」フレームワークになった後は、第一の考慮事項はもはや技術が正しいか、エレガントかではなく、ある種の後方互換性 + 安定性になるからだ(x
だから Evan You は別に何も間違ったことはしていない。むしろ誰かがこの を引き受けなければならなかったと言ってもよく、彼はたまたま最適な人選で、最適なことをしただけなのだ……
Solid
SolidJS はフレームワークとして優れており、コアも十分に安定している。ただ、誰もそのエコシステムを作りたがらない。Solid は実のところ初期の Rust そのものだが、Rust はすでにその時期を乗り越えた。Rust もプログラミングの領域ではエコシステムが乏しく何でも自分で車輪を作らなければならない状況に確かにあるが、Rust のツールチェーンは車輪を滑らかに作らせてくれ、最小限の動く単位を走らせてくれるので、車輪の再発明はそれほど難しくないのではないか。しかし不幸なことに SolidJS はまだこの段階を乗り越えておらず、そこに AI の冬 が来てしまった。特に 2026 年以降は非常に厳しくなる。Solid 2.0 のコアはすでにとても良く、安定もしているが、周辺が転がり出していない。発想は完全に正しく、ただ生まれた時期が悪かった。わずかに存在する周辺ライブラリも、一つ二つの散発的な領域にしかなく (たとえば TanStack には Solid 向けの実験的 Router がある) 、完全なエコシステムを組み上げられない。そのため現在も将来も気まずい立場に置かれている。完全なエコシステムがないまま、Agent が爆発的に広がる時代を迎え、雪玉が転がり出していなければ Agent は優先して選ばず、AI が優先しないなら貢献を得る機会も減る。特に Linus Torvalds までもが を始めた時代にあっては。Solid は本当に惜しい。発想は良く、技術は正しく、実装は優雅なのに、生まれた時期が悪かったことが心から残念だ...
「かつての私ならこれを問題だとは思わなかったかもしれない、何もかも自分で書けばいいと。しかし残念ながら、今の私はもうそうは思わない」
バランスポイント
そしてこの道の上で、まさに完璧なバランス点を踏んでいるのは実は Svelte だけだ。SolidJS よりも良いエコシステムを持ち (生まれた年代が良かった)、性能もさほど劣らず、時には Compiler の恩恵で、むしろ SolidJS より速くて優れていることさえ多い?! もちろんここで、ページ数が一定の規模まで増えたり、ユーザーが閲覧すればするほど、Svelte のリソースオーバーヘッド18が SolidJS と比べてどんどん大きくなるのでは?ネットワークリクエストがどんどん増えるのでは?と言う人が必ずいるだろう。Well、理論上それは事実だが、実際のウェブページに照らして見るべきだと思う。個人サイトやコンテンツサイトの 99.9% では、訪問者は基本的に長く留まらず、長時間の滞在はむしろ非常に稀なケースとみなしてもいいくらいだ。少なくとも私のこのサイトでは、今のところ深い連続クリックは極めて少ないので、「たくさんページをめくる = Svelte のほうが損」は私にとって問題にならない。しかしそれが Svelte を選んだ理由ではなく、核心はあくまで markup AST だ。
それから Dashboard のような、ページを次々に開いていくものについては、この統計は役に立たないが、Dashboard のユーザーは本当のコンテンツ読者に比べればずっと少ないではないか。だから「大量のページを閲覧するとオーバーヘッドが大きくなる」というのは、本質的には偽の命題だと私は考えている。少なくとも今のところは。将来は違う見方をするかもしれないが、この速いテンポの情報時代において、そうしたユーザーが極めて少ないことは認めざるを得ない。仮に誰かが興味から深く探索し、長期的なファンになったとしても、その探索はおそらく初回訪問でしか起きず、その後は新しく公開された記事を見に来るだけだろう。統計的なリターンで見れば、この極端なケースは割に合わないと言える。したがって私はこう信じたい。ほとんどの場面で Svelte のほうが総合的な性能に優れる。
Svelte は上記 3 つのフレームワークに対して立ち位置がちょうどよいだけでなく、実は素の HTML を書く感覚にかなり近い。それどころか、Svelte 3 が普及し始めて .svelte という拡張子が決まったばかりの初期には、公式の VSC 拡張が出るまでこれが推奨されるやり方だったほどで、今でも Svelte の拡張機能ページ にこの一文が残っている:
VSCode の設定に
"files.associations": {"*.svelte": "html" }を追加していたなら、削除してください。
それに加えて、Svelte は私に多くの恩恵を与えてくれる。まず、UI を JSX と大量の JS function19 へ押しやらないこと。そして <style> が runtime ではなくコンパイル時の産物であること。前者は構造を markup AST の中に留め、後者は CSS を直接 IR に入れられるようにし、Tailwind はちょうどその両側に貼り付けられる(
ブラックボックス
前回の記事に書いたとおり、Seam はまず一つのプロトコルであって、また一つの SSR runtime ではない。Build-time が slot を持つ HTML skeleton を生成し、request-time は injection だけを行う。if / each / match も同じくプロトコルノード20
惜しいことに、私は先に UI に React を選んで手を付け始めていた。不幸にも React の上でページ構造を捕捉して変体を生成するのは非常に難しく、コンパイラを改造するとなれば、その作業量はひとつ書き直すのと変わらない...
改造しないなら、React コンパイラをブラックボックスとして扱い、実際に大量の render + diff を実行して構造を推測して使うこともできる。nullable / enum のような有限の分岐ならまだ全列挙できるが、price > 10 のような述語に出くわした途端、型の値空間はとても掛け合わせきれない。V1 ではこういう場合、手動で mock を渡してもらうしかなかった。たとえば price < 10 / = 10 / > 10 で三回切る、といった具合に;
しかし問題はまさにここにある(?) この正確な 3 つの切り分けを実現するには、切れ目が 10 にあることを知っていなければならないが、React コンパイラは私にとって不透明で、条件が何なのか分からない。そのため TypeSafe21 の代償は、ユーザーがコンパイラの仕事をすることになってしまう。
理論上これはユーザーに丸投げすべきではなく、さもなければ結果は escape だらけ22になり、CTR23 がカバーするのは相変わらずあの程度の nullable / enum だけで、構造の発見はやっていないのと同じになり、どれほどの意味が残るのか分からなくなる。
ただ今回もプロトコルそのものを変えるという話ではない。前に書いた skeleton、slot、injection は実はそのまま残せると思うし、バックエンドが UI コードを走らせなくてよいのも変わらない。変えるべきなのはプロトコルの手前の層、つまり回答ページがどうやってこれらのノードへとコンパイルされるのかという問題だ。
そしてここでどうしても感謝しなければならないのが、Svelte がくれた最大の贈り物―― markup
だって markup の構造は、私が推測する必要がまったくないのだから!!!
見える AST
「markup が本物の markup である」という一点だけでも、ページ構造は runtime で JS を実行して生成されるコンポーネントツリーではなく、コンパイラが読めるものになれる可能性が絶対にある。正直に言えば、まともな UI がなぜ必ず任意実行で描画できなければならないのか?この考え方を否定しているわけではない、 ただ言いたいのは、少なくとも 97% 以上の時間、任意実行による描画は使われないということだ、詳しくはこちらを参照
反例を挙げると、React では
function Card({ user }) {
return user
? <div className="card">
<Avatar user={user} />
<span>{user.name}</span>
</div>
: null
}本質的にはやはり JavaScript function を実行して、何を返すかを見ているに過ぎない。コンパイラがどれほど賢くても、向き合う基本モデルは依然として JS execution → JSX expression → element tree だ。一方 Svelte ではまったく事情が異なる
{#if user}
<div class="card">
<Avatar {user} />
<span>{user.name}</span>
</div>
{/if}それがコンパイラに渡すのは、そのまま
Component
├── IfBlock
│ └── Element div
│ ├── Component Avatar
│ └── Element span
│ └── Expression user.name普通の Kit アプリにとってはこれだけでも十分に良いのだが、Seam のように server rendering を compile-time skeleton + CTR + SSR fallback に分解したいものにとっては、その意味は少しどころではなく、本当に大きい。
この木が同時に二層の情報をくれるからだ。まず 値の動的性 → slot だが、これは V1 の時点でとっくに実現できていた。より重要なのは後者が 構造の動的性 → プロトコル制御フローノードをくれることで、React のコンパイラというブラックボックスを推測するより、Svelte のコンパイラはまともなフロントエンドをそのまま読めるように晒してくれている。たとえば {user.name} が前者で、{#if user} こそが後者だ。
それから、旧プロトコルの if / each / match は今も有効だが、以前の旧記事にあったデカルト積の話は限定しておくべきだと思う。成り立つ条件は実は分岐空間であって、値空間ではない。nullable / enum / bool のような有限の決定なら組み合わせは有限で、compile-time を払って全列挙するのは数学的に成立する。しかし JTD の string / number / timestamp はそもそも列挙不可能で、price > 10、inventory < 5、items.length === 0 のような述語が切り出す分岐は、型の値のデカルト積だけでは発見できない。sentinel にどんな数を入れても、永遠に同じ側を通る。
本当にそれらを有限の決定へと畳み込むつもりなら、そもそもまず derive して bool / enum にする必要があり、その一歩は JTD がただでくれるものではない(x) だから V1 は nullable / enum の上では sound が、任意のフィールドの値空間に対してはそうではない。本当の工学的問題はむしろ、それらのブロックが diff で推測されたものであり、IR が自分自身を説明できないことだ。
Lowering
しかし Svelte の時代にはこれらのノードは AST から直接生成できる。そうする理由はスタックを乗り換えて数 ms を節約するためではなく、むしろ制御フローを観測24から lowering へ変えるためだ――構造の発見は AST に任せ、有限の決定はプロトコルノードで行い、N 個の HTML を掛け合わせて生み出す必要はもうない。型は payload を制約するのであって、木を発見するのではない。そうして最終的に Svelte では、values はこの Layer 上で以下のように見える ↓
STATIC
<div class="card">
DYNAMIC
{user.name}
STATIC
</div>この種の DYNAMIC の多くは、React ほどのレベルの「真・rendering25」を必要とせず、slot を保持するだけで十分である。
さらに細かく見ると、Svelte は単一の element をこんなふうに分解することもできる
structure: static
attributes: static
text node #0: dynamicこれこそ Seam が最も必要とし、最も好むものだ。React の中で attributes が static かどうかを知りたければ、基本的には総当たりしかない。考えられる入力をすべて走らせ、attribute が変わらなければ static、変われば slot を与える。
構造分岐はさらに悲惨で、「ここにこの DOM があるかどうか」ですら二回レンダリングしてから diff に頼るしかない。一方 Svelte の IfBlock は後者を観測できるものから生成できるものへと変えた。これこそが質的な飛躍だ。
ただ、実はこう言い切るのも完全に正しいわけではなく、あるいは単純化しすぎで、もう少し詳しく言えば、単一の element はさらに分割できる。しかし attributes が最初から static になるとは限らず、具体的にはどう書いたか次第だ
class="card" → attributes: static
class:active={on} → name static, value slot
{...rest} → opaque, escape hatch
text node {user.name} → slotたとえばここで一行目が skeleton に入ることに異論はないが、二行目こそが Svelte の本当に便利なところだ。class: / style: は attr の名前を AST に残し、値だけが slot になるので、element 全体を動的扱いにしなくて済む。三行目は React の {...props} と本質的な違いはなく、Seam は明示的な hatch として扱い、もう解析しているふりをしなくていい。React でもリテラルの className="card" は実際には列挙しなくてよく、列挙すべきなのは「別の props に差し替えたあとこの attr は変わるか」だ。Svelte がちょうど省けるのはこの最後の推測で、名前が木に残っている限り、二回レンダリングして確かめる必要はない 🫠
いずれにせよ、React は Seam に多くのものを改めて runtime problem として扱わせることになる。たとえば ↓
const Wrapper = cond ? A : B;
return Wrapper({
children: foo.map(renderItem)
});あるいは
return foo && bar
? getLayout()(data)
: something();もちろん現代の React Compiler はその多くを解析できるが、それに手を入れるということは、最後まで自分で保守し続けることを意味し、そのコストは労力でも時間でも桁外れに大きい... Svelte を改造するなら、ここでは compiler を fork するつもりはなく、理論上は svelte/compiler がすでに AST を露出している。
Seam がやるのはこの木を読んで、自前の IR を生成すること。公式の DOM / SSR codegen はそのまま fallback として残せるし、理論上は Svelte 全体ではなく IR lowering だけを保守すればいいので、大きな方向性は一致していて衝突は少ないはず――ただし一致しているのは「構造がコンパイル時に見える」という点であって、実行モデルではない。だからこそ Svelte / Kit の SSR は依然としてリクエストごとに生成された render 関数を一度走らせる。でも私の Seam では、request-time はこの関数を走らせず、プロトコル(slot + if / each / match)だけを解釈するようにしたい
Svelte を選んだ理由には実はもう一つの考慮があって、Next.js と React がすでに Vercel によって完全に26マーケット向けフレームワークとして掌握されている前提では、もし Seam が React semantics との互換を約束するなら、私の architecture は最終的に arbitrary JavaScript → determine tree structure を許容せざるを得なくなる。
そのため Seam の IR は極めて保守的にならざるを得ない(x) 最終的には、静的解析できるものは最適化し、解析できないものは SSR に回す、という形に落ち着く可能性が高い。それでも CTR + SSR を混在させたページにはなるが、問題はまさにその「解析できない」という部分にあり、React の世界には「確定できない」箇所があまりにも多すぎるのだ...
React のコンパイラに手を加えないなら、V1 と同じ状況に留まるしかない。Compiler をブラックボックスとして扱い、その生成物を当てずっぽうで推測するというやり方だが、それは非常に脆く、エレガントでもなく、エッジケースが至るところで噴出しやすい。
反転
だから、もし今ここで Seam の component language は Svelte そのものだと言うなら、compiler は逆向きに動くことすらできる。まず structure known をデフォルトにし、局所的な expression dynamic と明示的な escape hatch を加え、それから runtime に進み、どうしても処理できない場合にだけ最後に SSR へ fallback する。
<script>
let { product } = $props();
</script>
<article class="product">
<h1>{product.name}</h1>
<div class="price">${product.price}</div>
<button>Buy</button>
</article>Seam はそれを以下のような形として理解して構わない
<article class="product">
<h1><!-- slot 0 --></h1>
<div class="price">$<!-- slot 1 --></div>
<button>Buy</button>
</article>つまり理論上は slot0 = product.name, slot1 = product.price となる。これが以前から私がやっていた residualization で、核心となる考え方はページ全体を SSR に通す必要はなく、本当に dynamic な residue だけを計算するというものだ。
スロットとブランチ
しかし React から Svelte に switch して本当に得をするのは、実はその一つ下の層だ。構造が変わるときにもう二回レンダリングして推測する必要はない、構造はコンパイル時にすでに確定しているので、対応する IR をそのまま生成すればいい。
<article class="product">
<h1>{product.name}</h1>
<div class="price">${product.price}</div>
{#if product.available}
<button>Buy</button>
{:else}
<p>Sold out</p>
{/if}
</article><article class="product">
<h1><!-- slot 0 --></h1>
<div class="price">$<!-- slot 1 --></div>
<!--seam:if:product.available-->
<button>Buy</button>
<!--seam:else-->
<p>Sold out</p>
<!--seam:endif-->
</article>値は相変わらず slot で、分岐は旧プロトコルの if ブロックである。しかし React 版ではこのブロックを手に入れるまでに何度も diff が必要になるのに対し、Svelte ではここは IfBlock から直接導出して生成できる。
エスケープ
もちろん JSX にはできないと言っているわけではない。ただ実際に書くとなると、分岐ツリーの見やすい場所に構造を残す人はほとんどおらず、大体はこの下のように書いてしまう(x)
const price = formatPrice(product);
const body = product.available
? getAvailableView(product)
: getSoldOutView(product);
return <Layout>{body}</Layout>;さらにもう一つ重要な点がある。たとえ自分がこう書けたとしても、React の大多数がこう書かず、学習される LLM もこう書かないなら、移行コストも後々の開発コストも間違いなく指数関数的に膨らんでいく。
しかし Svelte はここでもまた違ってくる
UI structure ≈ template AST、JavaScript ≈ values + behavior は、ここではもう日常です
それどころか、Svelte が公式に最も推奨しているやり方でもある。この違いは Seam が IR を抽出するうえで構造的なものだ。ただし、Svelte に動的な構造がないと理解しないでほしい。実際には対応する逃げ口があり、しかも主要な構文のひとつだ
{@render children()}
<svelte:component this={layout} />
<svelte:element this={tag}>
{@html html}Wrapper = cond ? A : B はここで何もなく消えたわけではなく、「任意の JS 式」からこれら数個のノードに絞り込まれたのであり、Seam にとっては非常に単純になる。デフォルトで AST lowering を通すという原則に従えばよく、静的に見える hole は、その中にあるものの所有となる。最もよくあるのは {@render} を compose として扱うケースで(layout、Card が children を包むものはすべてこの型)、<svelte:component> / <svelte:element> / 動的な {@render} こそが明確な hatch であり、runtime か SSR fallback に入る。また {@html} については、私が直接設計した raw HTML slot をそのまま流用できる。
つまり実際の強みは「構造が常に既知である」ことではなく、「未知の構造には参照リストがある」ことなんですよね (x
React では未知なのは JS 全体だが、Svelte では未知なのは上に挙げたいくつかの Escape の開口部だけで、それ以上ではない。これを使えば、CTR が解析しなければならない内容の範囲は確実に大きく減る
CTR
Svelte で書けるからといって必ず CTR に乗るとは限らないが、ここに参考になる基準がひとつある ↓
こうして静的参照は compose となり、hatch は引き続き下のパスを通る。ここから、CTR の単位は静的コンポーネントグラフであって単一ファイルではないと分かる。大半はデフォルトで structure known、一部は明示的に escape hatch、SSR fallback には普段ほとんど出くわさない。表にない残りはすべて opaque として扱えばよく、推測する必要などまったくない。CTR はこの部分集合上の execution model にすぎず、たとえ Svelte に切り替えたとしてもSvelte 全体の範囲27を担保するつもりはないのだ(x)
CSS
もう一つの大きな収穫は実は CSS で、Svelte では次のように書ける
<div class="card">...</div>
<style>
.card { border-radius: 8px; }
</style>ここで Seam が component をコンパイルするとき、すでに markup dependency + script dependency + style dependency を同時に手にしており、しかも style は静的な artifactだ。そのため、非常にアグレッシブに判断できる
さらに一歩進めて、フローをこのような形にすることもできる ↓
上記の流れは JS runtime の関与をまったく必要としない
CSS-in-JS は使わない
そして CSS-in-JS も受け入れない。とはいえ、React に下のような書き方への対応を強いられたからではない
const Button = styled.button`
color: ${p => p.primary ? "red" : "black"};
`;OR
<div css={theme => ({
color: theme.colors.primary
})}>ただ、上記の構造は実は V1 の時点で Tailwind で回避できていた。CSS-in-JS を繋がない主な理由は、一度繋いでしまうと CSS が JS execution result に戻ってしまい、style runtime、registry、SSR collection、hashing、hydration consistency、theme context、insertion、ordering、dedup を全部呼び戻さなければならなくなることだ。普通のフレームワークなら飲み込める。でも Seam は違うんだ!私が消し去りたいのはまさにこの種の server / runtime work であり、エコシステムへの礼儀のために追い出したばかりのものを呼び戻す必要は全くないはずだ(何の道理もない
それに、CSS-in-JS が登場した時代に解決していた問題は、今なら Tailwind CSS & Motion がやっていることそのものだ。Tailwind CSS & Motion がすでに十分に、むしろもっと上手くエレガントに解決しているのなら、なぜわざわざ後戻りする必要があるのか。前に書いたとおり「どんなものもエコシステムが育つと妥協28が現れ、breaking な刷新のためのリファクタはめったに起きなくなる」のだから、Seam にまだエコシステムがないのなら、徹底的にラディカルにいかない手はないだろう?
ただ、ここで「過激」という言葉が正しいのかは分からない。正しいと言える面は、従来のフレームワークと比べて、サーバーサイドの重い JS ランタイム (Node か Bun) を思い切って捨てているところだろう。正しくないかもしれない面は、たとえば CSS-in-JS を完全に捨てたとしても、実のところ何の問題もないという点だ。むしろそれを と考えたい。かつて最も有名だった CSS-in-JS ライブラリたち、Stitches は 2023 年 6 月に公式に no longer actively maintained と宣言し、styled-components も 2025 年 3 月に休眠状態に入り、Emotion もほぼ同じような状況だ。
そうなるとここでもまた Svelte の <style> を褒めざるを得ない。Svelte の中では CSS → compile-time artifact はもはや議論の余地のないものになっている (少なくとも私はそう思っている、もし同意できないならあなたが正しい)
JS → execute → generate CSS → collect → serialize → hydrate でない限り、差はかなり大きくなる。さらに重要なのは、CSS dependency をそのまま Seam IR に入れられるかもしれないということだ 🤔
例えば component compilation の場合:
ComponentIR {
skeleton,
dynamic_slots,
css,
client_behavior,
server_dependencies
}これは別のレンダリングモデルを新たに立てたわけではない点に注意してほしい。V1 では skeleton + dynamic_slots をコンパイルし終えたものが、そのまま旧プロトコルのあの HTML であり、ただ skeleton + dynamic_slots がどう生まれるのかが私にとってより透明になっただけだ。
次のように処理すると仮定してみよう ↓
<script>
let { name } = $props();
</script>
<div class="user">Hello {name}</div>
<style>
.user { font-weight: 500; }
</style>すると最終的には必ずこうなる
ComponentIR
HTML: <div class="user svelte-x">Hello SLOT(0)</div>
CTR: slot(0) = props.name
CSS: .user.svelte-x { ... }
Client: none
SSR: noneただし、ここまで見てきたのはあくまで葉であり、しかもかなり理想的なスライスだという点に注意してほしい。子コンポーネントも、構造上の分岐も、client もない。ページレベルの skeleton は静的なコンポーネントグラフから compose されたものであり、サブツリーに $state や hatch が現れた途端、IR 内の Client / SSR は none ではなくなる。
しかしこれはすでに、私が Seam に到達してほしい理想の状態にかなり近い。つまり Svelte component が「実行しなければならない renderer」に対応するのではなく、Svelte 5 Compiler が分解できるリソースになるということだ。これを実現できれば、私にとってはすでに非常に大きな conceptual improvement である。
所有権
それから、「global state を推さない」ことが Seam にとって持つ意味は DX よりも深い。Next.js のプロジェクトでも、あるいは他のどんな React フルスタックフレームワークでも、きっと下のような塊を見たことがあるはずだ ↓
<ThemeProvider>
<AuthProvider>
<QueryClientProvider>
<I18nProvider>
<RouterProvider>
<App />
</RouterProvider>
</I18nProvider>
</QueryClientProvider>
</AuthProvider>
</ThemeProvider>もちろん、これに解決策がないわけではない。この「ピラミッド」状の Provider のネストは、実は Compose Providers として書くこともできる。だいたい下のような形になる。
具体的には、もともと積み重なっていた Provider を配列に放り込み、合成する関数を一つ定義して、最後にその一層だけを使えばいい。ただ、私にとってこれは一種の自己欺瞞でしかない。本質的には「隠した」だけで、本当に消えたわけではない。見た目のネストが論理上のネストに変わっただけで、ある意味では 、純粋な自己満足にすぎない。
const AppProviders = composeProviders([
ThemeProvider,
AuthProvider,
QueryClientProvider,
I18nProvider,
RouterProvider,
]);
function composeProviders(providers: React.FC<{ children: React.ReactNode }>[]) {
return ({ children }: { children: React.ReactNode }) =>
providers.reduceRight(
(acc, Provider) => <Provider>{acc}</Provider>,
children
);
}
function Root() {
return (
<AppProviders>
<App />
</AppProviders>
);
}React プロジェクトは最終的に本当に簡単に、というより 100% こうなってしまう。そうなると Seam がとても嫌がる問題、つまり「component の入力とは一体何なのか?」が生じる。表面上は <ProductCard product={product}/> が product 自体にしか依存していないように見える? けれど実際はどうだろう
depends on:
product
theme
locale
queryClient
router
auth
featureFlags
...そうして component dependency graph はまた暗黙的なものに戻ってしまう。traditional SSR ならさほど問題にはならず、最悪でも「React tree 全体をもう一度走らせる」だけで済む。しかし Seam が最終的に問いたいのは ProductCard を単独で CTR できるかどうかであり、そうなると本当に厄介になる。React コンパイラ全体の挙動と、ユーザーの DX や習慣まで変えなければならないとさえ言えるわけで、そこまでする必要があるのだろうか...
Next.js を使っている人は基本的にもう性能を気にしていない29と、私は責任を持って言える。彼らが払っているのはむしろ Vercel の周辺インフラへの対価であり、Next.js を Vercel に hosted せずに使うなら、本当に何の恩恵も受けられない 😡
局所的なオーナーシップ
一方 Svelte は、局所的な ownership を非常に形成しやすい。
<script>
let { product } = $props();
let quantity = $state(1);
</script>Svelte / Seam Compiler の視点から見ると、これはとても快適だ
なにしろ 「input: product + local client state: quantity」で Ownership が本当にはっきりしているのだから
product.name → server CTR
quantity → client
button onclick → client
rest → staticしかしこれはあくまでデフォルトであって、保証ではない。getContext('theme') と .svelte.ts の中の rune module は依然として暗黙の入力だし、QueryClient も勝手に消えてくれるわけではない。Seam の姿勢は前述の hatch リストと同じになると思う。見えたものは IR に記録し(server 依存または client の所有権)、解析できないものは opaque として扱う。「Svelte に Provider なんてものはない」というふりをしないほうがいいだろう。とはいえ断言はできない。もう少し調べれば Provider の扱い方は見つかるかもしれない
React 時代の Seam の最大の問題は、IR が Component → execute renderer → get output と進んでいたことで、結局私が seam compiler と呼んでいたものは、ある意味で早めの SSR になっていた、あるいは端的にこう言い切ってもよいものになっていた
「universal SSR orchestration layer」
同じプロトコル
つまり今後は UI は Svelte である と言えるが、seam プロトコル自体はあくまでプロトコルのままで、依然として slot と injection が中心であり、JTD が本当に適切かどうかはさらに検討の余地がある。実行判断の面では、すでに完全にレイヤー分離されており、スロットはすでに residual 化されているので、Rust / Go / TS サーバーはコンポーネントのコードをまったく理解せずに、ただ埋めるだけでよい。本当の意味でのゼロ JS である。同時に、コンポーネント内にまだ純粋な派生処理が残っているなら、generated JS / QuickJS runtime を加えることを検討してもよい。(ここで動いているのはデータであって UI ではないことに注意)
hatch のものだけが SSR fallback するので、本物の SSR が不要な場合、バックエンドは UI コードを走らせなくてよく、当然 Node OR Bun に縛られることもない。ただし (Go Server はメンテナンス停止の予定、個人の力が足りないからで、時間のある親切な方がいれば拾って長期メンテしてくれて構わない。アーキテクチャがバックエンド非依存をやめたわけではない)
言い換えれば、私が切り落としたのは UI だけ、つまり誰の要望でも引き受けるような骨の折れてあまり報われない部分だ (?)。変わらない部分は引き続き「Rendering Protocol」として機能し、バックエンドは JS/TS に限定されない。
Runes
もうひとつ非常に重要な点は、Svelte 5 の runes が Seam にとてもなじみやすいことだ。Svelte 5 は少し JS 寄りになり始めたとはいえ
let count = $state(0);
let doubled = $derived(count * 2);ただしこの JS はあの JS ではない30。useState / useMemo / useContext / useEffect との違いは、runes が依然として compiler-recognized semantics であることだ。だから Seam は $state が client の所有物であり、$derived が派生であり、$effect が client runtime であると知ることができる。これは const foo = someLibraryHook() とは次元が違う。
データの出所
ここで重要なのは、「認識できる」ことと「CTR できる」ことは同じではないという点で、特に $derived を見ただけでそれが skeleton に入ると決めつけてはいけない。実際、上の count * 2 はその反例のひとつで、依存先が $state である以上、これは client 側の派生物だ。CTR できる $derived は実際にはこういう形をしている:
let { product } = $props();
let price = $derived(formatPrice(product.price));product は $props に由来し、formatPrice は純粋かつ可視である必要があり、前の表に当てはめるとこうなる
$state / $effect / onclick → client
$derived
deps ⊆ server data, pure, visible → CTR derive
deps include $state → client
impure or opaque callee → QuickJS or SSR fallback
markup → follow the table above; runes are not a free CTR passこうやって図に描いてみたほうが分かりやすいかもしれない(?)
とにかく Runes フレンドリーというのは、所有権が構文上に示されているということであって、$derived が CTR の資格を最初から持っているからではない。判断基準はやはり唯一、下のパスが示すように、データがどこから来るか、純粋かどうか、見えるかどうか、というだけ
そしてその隣に <style> → static CSS dependency graph を一つ添えれば、世界観全体が非常に一貫したものになる。HTML は structure、CSS は style artifact、JS は必ず computation / behavior だ。
現代の React が陥りやすい All in JS31 ではなく。 React 自身にとってそれは必ずしも悪いことではないのかもしれないが、残念ながら Everything is JavaScript は、私が aggressive な compile-time decomposition を行うにあたって、まさに最も不利な世界なのだ
何が Ship されるのか? 32
ここまで長々と述べてきたが、最終的に本当に実現されるものは何か(x) まず疑いなく UI スタックを Svelte に置き換えることであり、それが済めば Compiler の可観測性が明らかに高まることが期待できる。主な理由は、Svelte が structure を markup AST の中に留めることを強制し、かつ奨励していて、arbitrary JS control flow の中に埋め込ませないからだ。これにより skeleton / CTR の解析可能な範囲が直接広がり、CSS が runtime concern から再び build artifact へと戻る。
つまり Seam は component-level CSS dependency、critical CSS、tree shaking、lazy CSS に取り組む余地を得られ、server / client の style runtime を別途維持する必要もなくなる。
それから、Component ownership が徹底的にクリーンになる。Provider / Context / global runtime dependency が減れば、どの値が Server 由来で、どこで CTR として渡せて、誰に client boundary が必要で(どの state が client のものか)、この component に本当に hydrate が要るのか……を、ようやく徹底的に答えられる機会が持てるということだ。
この三つのうち前の二つが、CTR が単なる optimization にとどまるのか、それとも本当に SeamJS の主要な execution model33 になれるのかを直接決める。だからこそ私にとって React を捨てることは必ずしも技術的な妥協ではなく、逆に構造上の breaking にもなりうる。 ともかく、私の言いたいことは変わらない
どんなものでも、エコシステムが立ち上がったあとは妥協が生まれる。
破壊的なリファクタリングで刷新することはめったになくなる
だからまだ本当に軌道に乗っていない以上、当然 breaking な変更をする機会はいくらでもあり、当面は移行コストを考えなくていい。そして数え切れない breaking の果てに、本当に自分の居場所を見つけられる日が来ることを心から願っている。
たとえ最後まで使うのが私一人だけだとしても、これは私自身の 「Experiment 🧪」