Core Package · Reactive Core (HTML-first)
Reactive state management with declarative data binding. The only contract between UI and state is a path string — user.name, cart.items.*.subtotal. No virtual DOM, no build step, no hooks.
パス文字列だけがUIと状態の契約。仮想DOMなし、ビルドなし、フックなし。ES Proxyによる純粋なリアクティビティ。
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
On this page
wcstack ships two reactive cores that drive the same wc-bindable I/O nodes. Pick the one that matches where your logic lives.
wcstackには同じwc-bindableタグ群を駆動する2つのリアクティブコアがある。ロジックの置き場所で選ぶ。
data-wcsfor / if directivessignal / computed / effect in plain modulesh() DOM, keyed For / IndexA <wcs-state> element manages reactive state. data-wcs attributes bind DOM to paths like user.name or cart.items.*.subtotal. The component's JavaScript never references the DOM; the HTML alone describes every data dependency.
<wcs-state>が状態を管理し、data-wcs属性がDOMをパスに結びつける。JavaScriptはDOMを一切参照しない。
Reactivity is driven by path assignment. Assign to a path via this["path.to.value"] or a top-level property — deep in-place mutation is not detected.
更新は「パスへの代入」で検出される。オブジェクトの深い直接変更は検出されない。
this.count = 10; // ✓ detected
this["user.name"] = "Bob"; // ✓ detected
this.user.name = "Bob"; // ✗ NOT detected — no path assignment
// Arrays: non-destructive reassignment
this.items = this.items.concat({ id: 4 }); // ✓
this.items = this.items.toSpliced(0, 1); // ✓
this.items.push({ id: 4 }); // ✗ in-place mutation not detected
These are not conditional suspicions — the undetected shapes always drop the update. As of v1.31.0 the linter treats all three (wcs/nested-assign, wcs/array-mutation, wcs/array-index-assign) as errors, so wcs-validate fails a build containing any. And a bound path that provably does not resolve (user.nmae) warns once at binding time with wcs/binding-path-missing and a did-you-mean — the check under-approximates, so absence of a warning proves nothing.
これらは条件つきの疑いではない — 検出されない形は常に更新を落とす。v1.31.0からlinterは3つすべて(wcs/nested-assign・wcs/array-mutation・wcs/array-index-assign)をerrorとして扱い、1つでも含むビルドはwcs-validateで落ちる。また、確実に解決しないバインド済みパス(user.nmae)はバインド時にwcs/binding-path-missingとdid-you-meanつきで一度警告される — 検査は過小近似なので、警告がないことは何の証明にもならない。
v4.0.0: writing a top-level key the state does not have creates it (this.later = 5; 3.x failed the write), while reading one throws [wcs/binding-path-missing] Path "later" does not exist on the state tree. (#301) — so declare every key you bind, null included, in the initial state. A deeper path that does not exist (user.nickname) reads as undefined, and the did-you-mean warning comes from the diagnostics feature, which the full entries include.
v4.0.0: stateに無いトップレベルのキーへの書き込みはそれを作る(this.later = 5。3.xは書き込みを失敗させていた)。一方でその読みは[wcs/binding-path-missing] Path "later" does not exist on the state tree.(#301)をthrowする — だからバインドするキーは、nullも含めてすべて初期のstateで宣言すること。存在しない深いパス(user.nickname)の読みはundefinedで、did-you-meanの警告は完全版のエントリが載せるdiagnostics機能が出す。
state="id" → a <script type="application/json" id> elementsrc="*.json" or src="*.js" → external filejson='{ }' → inline JSON attribute<script type="module"> → export defaultsetInitialState() → programmatic APIv3.5.0: the browser evaluates an inner <script type="module"> itself as well — being inside <wcs-state> does not stop it. Its export goes nowhere, so the state is unaffected, but its top-level code runs twice, once by the browser and once by state: keep requests, logging and assignments to globals out of the top level. Under a Content-Security-Policy, state imports that script through a blob: URL, and a module’s import() inherits the nonce of the <script> that loaded it, so the page nonce on the <script> that loads state covers it (checked on Chromium, Firefox and WebKit — the docs used to say a nonce could not help). script-src blob: is needed only where no nonce can be issued, and src="./state.js" needs no extra directive. Unless the inner script carries the nonce too, the browser’s own run of it is refused with one console violation, and state still loads. A CSP block of that import is now named as one on Firefox too, which fires the violation a task after the failed import. Details: docs/csp.md.
v3.5.0: 内側の<script type="module">はブラウザ自身も評価する — <wcs-state>の中にあってもそれは止まらない。そのexportはどこにも届かないのでstateには影響しないが、トップレベルのコードは2回走る。ブラウザが1回、stateが1回だ: リクエスト・ログ・グローバルへの代入はトップレベルに置かないこと。Content-Security-Policyの下では、stateはそのスクリプトをblob:のURLでimportし、モジュールのimport()はそれを読み込んだ<script>のnonceを受け継ぐので、stateを読み込む<script>にページのnonceを付ければそれで通る(Chromium・Firefox・WebKitで確認済み — 文書はnonceでは助けにならないと言っていた)。script-src blob:が要るのはnonceを発行できない場合だけで、src="./state.js"なら追加のディレクティブは要らない。内側のスクリプトにもnonceを付けない限り、ブラウザ自身によるその実行はコンソールの違反1件とともに拒まれ、stateは変わらず読み込まれる。そのimportがCSPで止められたことは、失敗したimportの1タスク後に違反を発火させるFirefoxでも、いまはCSPとして名指しされる。詳細: docs/csp.md。
v4.0.0: a source that cannot be read fails the element’s initialization instead of starting an empty state. state="id" reads only a <script type="application/json"> of that id — in the element’s own root first (a shadow root), then in the document — and without one fails with #16; a src="*.json" that cannot be fetched fails with #13 and the HTTP status, and JSON that does not parse and a module that throws fail initialization too (see Lifecycle hooks for how a failure is reported). An empty JSON script is {}. A root <wcs-state> taken out of its root while its state loads mounts nothing there and starts again where it is connected next: a state given by setInitialState() is kept for that, but a state / src / json / inline source is read again, an inline module’s top-level code included.
v4.0.0: 読めないソースは、空のstateで始める代わりに要素の初期化を失敗させる。state="id"が読むのはそのidの<script type="application/json">だけで — 要素自身のルート(shadow root)を先に、次に文書を探す — 無ければ#16で失敗する。取得できないsrc="*.json"はHTTPステータスとともに#13で失敗し、解析できないJSONやthrowするモジュールも初期化を失敗させる(失敗の報告のされ方はライフサイクルフックを参照)。空のJSONスクリプトは{}だ。stateの読み込み中にルートから取り出されたルートの<wcs-state>は、そこには何もマウントせず、次に接続された場所で始め直す: setInitialState()で渡したstateはそのために保たれるが、state / src / json / インラインのソースはもう一度読まれる。インラインのモジュールのトップレベルのコードも同じだ。
mount)There is one state tree per root. To split state across modules, mount a volume: its data is grafted onto the root tree at the mount path, and bindings read it by prefix. This is mount /dev/sdb1 /mnt/data, not a second namespace. The root <wcs-state> is required (it may be empty), load order does not matter — a volume connected before the root is grafted when the root’s engine is created, before the page is bound, and one connected while the root is out of the page is grafted when it is put back — and mount paths are static (*, $, #, @ and whitespace are rejected). A volume holds data, getters, setters and methods, and may declare $connectedCallback / $disconnectedCallback, all relative to its mount point; 4.0 makes its methods reachable by path (onclick: cart.checkout, this["cart.checkout"]() from the root) and refuses a volume that declares what reacts to the whole tree (below).
stateの木はルートごとに1本。モジュールに分けるときはボリュームをマウントする。データはルートの木のマウントパスに接ぎ木され、バインディングは接頭辞付きのパスで読む。第二の名前空間ではなくmount /dev/sdb1 /mnt/dataである。ルートの<wcs-state>は必須(空でよい)で、ロード順は問わない — ルートより先に接続されたボリュームは、ルートのエンジンが作られるとき、ページがバインドされる前に接ぎ木され、ルートがページの外にある間に接続されたボリュームは、ルートが戻されたときに接ぎ木される — マウントパスは静的(* $ # @と空白は不可)。ボリュームはデータ・getter・setter・メソッドを持ち、$connectedCallback / $disconnectedCallbackを宣言でき、どれもマウント点からの相対だ。4.0はそのメソッドをパスで届くようにし(onclick: cart.checkout、ルートからはthis["cart.checkout"]())、木全体に反応するものを宣言したボリュームを拒む(後述)。
<wcs-state src="./app.js"></wcs-state>
<wcs-state mount="cart" src="./cart.js"></wcs-state>
<div data-wcs="textContent: cart.total"></div>
Migrating from v1 named state. name= and path@name were removed in v2: name="cart" becomes mount="cart", total@cart becomes cart.total, and @default simply drops. The runtime fails fast on name= and treats @ in a path as a parse error, both printing the replacement, and wcs/named-state-deprecated lists every remaining site as a lint error. One volume caveat: $stream raises on a volume, and $commandTokens / $eventTokens / $on belong on the root — those are the only declarations that are not a plain rename.
v1のnamed stateからの移行。 name=とpath@nameはv2で削除された。name="cart"はmount="cart"に、total@cartはcart.totalに、@defaultは単に外す。ランタイムはname=で即座に失敗し、パス中の@はparse errorになり、どちらも置き換え先を表示する。残った箇所はwcs/named-state-deprecatedがlintのerrorとして列挙する。ボリュームでの注意は1つ — $streamはボリュームでは不可、$commandTokens / $eventTokens / $onはルートに置く。単純なリネームで済まないのはこの4つだけ。
v2.5.0: a volume that settles without grafting releases its mount slot — it failed to load, its root failed and orphaned it, or it was detached while still loading or waiting for that root. Until now the slot stayed reserved for as long as the root node lived, so a replacement element on the same path was rejected with Volume slot "…" is already mounted on this tree. — an error nothing awaiting the page ever saw — and reloading was the only recovery. A released slot is taken back when the element is re-attached to the same root, or otherwise just before it grafts; if another volume took it meanwhile, that is reported and the graft is skipped. A grafted volume keeps its slot even while detached, a release only removes that element’s own reservation (so removing a dead volume never frees a slot another one holds), and reads under a released path still resolve to undefined. In the same release, setInitialState() on a loaded volume element throws: the graft copies a volume’s data into the root tree once, so re-setting the element only ever changed that element’s own reads. Write the paths under the mount path on the root instead.
v2.5.0: 接ぎ木せずに決着したボリュームはマウントスロットを解放する — 読み込みに失敗した、ルートが失敗して孤立した、読み込み中やルート待ちのまま外された、のいずれでも。これまではルートノードが生きているかぎりスロットは予約されたままで、同じパスへの代わりの要素はVolume slot "…" is already mounted on this tree.で拒否された — ページを待っている誰にも見えないエラーだ — 復旧はリロードしかなかった。解放されたスロットは、同じルートへ再接続されたとき、あるいは接ぎ木の直前に取り戻される。その間に別のボリュームが取っていれば、それが報告され接ぎ木は行われない。接ぎ木済みのボリュームは外れていてもスロットを保ち、解放はその要素自身の予約だけを外し(死んだボリュームを取り除いても他のボリュームのスロットは空かない)、解放されたパスの下の読みは従来どおりundefinedになる。同じリリースで、読み込み済みのボリューム要素へのsetInitialState()はthrowする: 接ぎ木はボリュームのデータをルートの木へ一度だけコピーするので、要素の再セットはその要素自身の読みしか変えなかった。代わりにルート側でマウントパス配下のパスに書くこと。
Injecting root paths into a volume (v3.1.0 – v3.5; refused in 4.0). 4.0 takes no injections: any data-wcs on a <wcs-state mount> counts as one, and the volume is not grafted (see v4.0.0 below). Read the root path in a getter on the root state instead — get cartTotalWithTax() { return this["cart.subtotal"] * (1 + this["settings.taxRate"]); } — and wrap the volume element in an element of your own to bind something to it. The rest of this subsection describes 3.1 to 3.5. When a volume’s code needs a path outside its own subtree, write the injection on the volume element’s data-wcs, in the same form as a component’s partial mount (until 3.1 such a data-wcs failed to apply as a write to a missing state property):
ボリュームへのルートのパスの注入(v3.1.0 – v3.5。4.0では拒まれる)。 4.0は注入を受けない: <wcs-state mount>のdata-wcsはどれも注入とみなされ、そのボリュームは接ぎ木されない(下のv4.0.0を参照)。代わりにルートのパスはルートのstateのgetterで読み — get cartTotalWithTax() { return this["cart.subtotal"] * (1 + this["settings.taxRate"]); } — ボリューム要素そのものに何かをバインドしたいなら、自分の要素で包むこと。この小節の残りは3.1から3.5の説明だ。ボリュームのコードが自分の部分木の外のパスを要るときは、ボリューム要素のdata-wcsに、コンポーネントの部分マウントと同じ形で注入を書く(3.1より前、そうしたdata-wcsは存在しないstateプロパティへの書き込みとして適用に失敗していた):
<wcs-state mount="cart" src="./cart.js" data-wcs="state.taxRate: settings.taxRate"></wcs-state>
Inside cart.js — getters, methods, $watch, $listKeys and the lifecycle callbacks — this.taxRate reads and writes the root’s settings.taxRate. The read records a dependency, so get total() re-evaluates when settings.taxRate changes; $watch: { taxRate() {…} } fires on it, and $renderedCallback receives that update under the inner name taxRate. The injected name exists only inside the volume’s code: the page keeps reading settings.taxRate, and the tree has no cart.taxRate.
cart.jsの中 — getter・メソッド・$watch・$listKeys・ライフサイクル — では、this.taxRateがルートのsettings.taxRateを読み書きする。読みは依存を記録するので、settings.taxRateが変わればget total()は再評価され、$watch: { taxRate() {…} }はそれで発火し、$renderedCallbackはその更新を内側の名前taxRateで受け取る。注入された名前が存在するのはボリュームのコードの中だけで、ページはsettings.taxRateを読み続け、木にcart.taxRateは無い。
state.<key>; state: … cannot move the whole volume — change mount for that.taxRate: 0) is not grafted. An accessor or method of the same name would make this.taxRate ambiguous, so that volume reports an error and does not graft.#ro. With state.taxRate#ro: settings.taxRate the value can be read but not written: an assignment, $setAll or a writing $resolve from the volume’s code throws [wcs/mount-readonly]. The root itself can still write the path.$ — a volume has no loop context — and takes no filters; derive the value in a getter. A malformed injection is reported as [wcs/mount-path-invalid] before the volume loads.規則は4つ。1つずつ: 左辺はstate.<key>1つで、state: …でボリューム全体を動かすことはできない — それにはmountを変える。注入はボリューム自身のキーに勝つ: 同名のデータキー(taxRate: 0)は接ぎ木されない。同名のアクセサやメソッドはthis.taxRateを曖昧にするので、そのボリュームはエラーを報告して接ぎ木しない。#ro: state.taxRate#ro: settings.taxRateなら値は読めるが書けない — ボリュームのコードからの代入・$setAll・書き込みの$resolveは[wcs/mount-readonly]をthrowする。ルート自身は今もそのパスに書ける。静的なパスだけ: 右辺はワイルドカードを含めず$で始められず — ボリュームにはループの文脈が無い — フィルタも取らない。値はgetterで導くこと。形の崩れた注入は、ボリュームの読み込み前に[wcs/mount-path-invalid]として報告される。
v3.3.0: the state namespace on a <wcs-state> without mount= reports [wcs/mount-path-invalid] — one key is told to move to a volume element, a bare state: that the name is reserved — instead of failing later as a write to a missing property, and .state: / .state.<key>: are rejected with [wcs/binding-syntax], because on a volume the dotted form was silently read as an injection. With several injections (state.a: settings; state.b: settings.tax), $renderedCallback names an updated path by the longest match, as reads do, instead of by the order they were written in. A volume written as a class grafts its prototype accessors — class Cart { get total() {…} } used to lose total without a word — and this.$x = … in a volume no longer creates a cart.$x key in the tree.
v3.3.0: mount=の無い<wcs-state>に書いたstate名前空間は、後で存在しないプロパティへの書き込みとして失敗する代わりに[wcs/mount-path-invalid]を報告する — キー1つならボリューム要素へ移すよう、素のstate:ならその名前が予約されていると告げる。.state: / .state.<key>:は[wcs/binding-syntax]で拒まれる。ボリューム上ではドット付きの形が黙って注入として読まれていたからだ。注入が複数あるとき(state.a: settings; state.b: settings.tax)、$renderedCallbackは更新されたパスを、書いた順ではなく読みと同じ最長一致で名付ける。クラスで書いたボリュームはプロトタイプのアクセサを接ぎ木する — class Cart { get total() {…} }は黙ってtotalを失っていた — そしてボリューム内のthis.$x = …が木にcart.$xというキーを作ることはもう無い。
v3.5.0: 4.0 will not graft a volume that injects root paths, or that declares $watch, $listKeys, $renderedCallback, $behavior or $features — it reports the volume with console.error instead. All of them still work in 3.x, and 3.5 names each one once with [wcs/v4-migration]. To be ready, read the root path in a getter on the root state, and move those declarations to the root state with absolute paths (see Migrating from 3.x).
v3.5.0: 4.0は、ルートのパスを注入するボリューム、あるいは$watch・$listKeys・$renderedCallback・$behavior・$featuresを宣言するボリュームを接ぎ木せず、代わりにそのボリュームをconsole.errorで報告する。3.xではどれも今も動き、3.5はそれぞれを[wcs/v4-migration]で一度ずつ名指しする。備えるには、ルートのパスはルートのstateのgetterで読み、それらの宣言は絶対パスにしてルートのstateへ移す(3.xからの移行を参照)。
v4.0.0: a volume no longer runs the declarations that react to the whole tree. One that declares $watch, $listKeys, $renderedCallback, $behavior or $features (or $stream, $recursion and the removed $scan, $streams, $updatedCallback), or that carries a data-wcs of any kind, is not grafted: console.error names the element and the reason — [@wcstack/state] <wcs-state mount="cart"> $watch is not run in a volume — declare it on the root state. — and its connectedCallbackPromise still resolves. Declare them on the root state with full paths: inside the root’s handlers this is the root ($watch: { "cart.total"(cur) { … } }), and the root’s $renderedCallback(paths) receives paths written from the root (cart.items.*.name), so filter them by the prefix. $commandTokens, $eventTokens, $on and $errorCallback in a volume are not run, with a console.warn. Other refusals say why as well: will not graft: the root state already has "cart"., will not graft: its component is wired to its host. (3.x left that one pending), and will not graft: the root state failed to initialize. for the volumes of a root that failed. A changed mount attribute is ignored without a message — remove the element and add a new one. The 4.0 lint reports these declarations as wcs/volume-declaration.
v4.0.0: ボリュームは、木全体に反応する宣言をもう動かさない。$watch・$listKeys・$renderedCallback・$behavior・$features(あるいは$stream・$recursionと、取り除かれた$scan・$streams・$updatedCallback)を宣言するボリューム、どんな種類であれdata-wcsを持つボリュームは接ぎ木されない: console.errorが要素と理由を名指しし — [@wcstack/state] <wcs-state mount="cart"> $watch is not run in a volume — declare it on the root state. — そのconnectedCallbackPromiseは変わらずresolveする。それらはルートのstateに完全なパスで宣言する: ルートのハンドラの中のthisはルートで($watch: { "cart.total"(cur) { … } })、ルートの$renderedCallback(paths)はルートから書いたパス(cart.items.*.name)を受け取るので、接頭辞で絞り込む。ボリュームの$commandTokens・$eventTokens・$on・$errorCallbackはconsole.warnとともに動かない。ほかの拒否も理由を告げる: will not graft: the root state already has "cart".、will not graft: its component is wired to its host.(3.xはこれを保留のままにしていた)、そして失敗したルートのボリュームにはwill not graft: the root state failed to initialize.。変更したmount属性はメッセージ無しに無視される — 要素を取り除いて新しいものを加えること。4.0のlintはこれらの宣言をwcs/volume-declarationとして報告する。
What each scope runs (3.0; 4.0). One table instead of rules scattered across sections — and nothing in it is ignored silently: a declaration a scope does not run is named by a warning or refused with an error. The table is 4.0’s; the 3.x column differences are in Migrating from 3.x.
スコープごとに何が動くか(3.0、4.0)。 節ごとに散らばっていた規則を1つの表にした — そしてこの表の中に黙って無視されるものは無い: スコープが実行しない宣言は、警告で名指しされるかエラーで拒まれる。表は4.0のもので、3.xとの違いは3.xからの移行にある。
| Declaration | Root <wcs-state> | Volume mount="p" | Mounted component (state: …) |
|---|---|---|---|
| Data keys, getters, setters, methods | yes | yes, relative to p; methods reachable by path (p.method) | yes — own keys private unless the host maps them; getters exported |
$connectedCallback / $disconnectedCallback | yes | yes, relative to p | yes |
$watch, $stream, $renderedCallback | yes | refused: not grafted, console.error | not run — one wcs/mount-dollar-declaration warning |
$listKeys | yes | refused: not grafted, console.error | yes, on the component’s own lists (3.x: not run) |
$recursion, ** getters | yes | refused | throws [wcs/mount-dollar-declaration] |
$commandTokens, $eventTokens, $on | yes | not run — console.warn | yes, on the component’s own bindings (3.x: not run) |
$errorCallback | yes | not run — console.warn | yes, for the failures of the component’s own bindings (3.x: not run) |
$behavior, $features | yes | refused: not grafted, console.error | yes, for the component’s own tree |
An injection on the element (data-wcs="state.<key>: …") | — | refused: not grafted, console.error | — (the host’s wiring is the mount) |
| An initialization failure | reported once; connectedCallbackPromise rejects | settles without grafting; connectedCallbackPromise resolves (a missing scopes feature rejects it) | reported once; the component’s connectedCallbackPromise rejects |
A component that is not mounted — a plain Shadow DOM child with its own <wcs-state>, or a Shadow DOM bind-component the host does not wire — is a root of its own and runs everything in the first column.
マウントされていないコンポーネント — 自分の<wcs-state>を持つ素のShadow DOMの子、あるいはホストが配線しないShadow DOMのbind-component — はそれ自身がルートで、最初の列のすべてを実行する。
export default {
async $connectedCallback() { // on connect (awaited)
const res = await fetch("/api/items");
this.items = await res.json();
},
$disconnectedCallback() { // on disconnect (sync)
clearInterval(this.timer);
},
$renderedCallback(paths, indexes) { // after updates (async, not awaited)
console.log("updated:", paths);
},
$errorCallback(error, { path }) { // a binding failed to apply (not in a volume, not awaited)
this.loadError = `${path}: ${error.message}`;
}
};
v3.2.0: the canonical name of $updatedCallback is $renderedCallback. The hook receives updates of applied bindings, not every state update (that is $watch), and the new name says so. The old name works the same through 3.x and is removed in 4.0; declaring both fails with [wcs/declaration-alias], and error messages now name $renderedCallback. On 3.1 or earlier, write $updatedCallback. 4.0: a state that declares $updatedCallback fails with [wcs/declaration-alias] $updatedCallback was removed: write $renderedCallback. (#1601) where the diagnostics feature is installed (@wcstack/state, /auto); on /core without it the key is ignored.
v3.2.0: $updatedCallbackの正規の名前は$renderedCallbackになった。このフックが受け取るのは適用されたバインディングの更新であって、stateのすべての更新ではない(それは$watchの役目だ)。新しい名前はそのことを言っている。旧名は3.xの間は同じに動き、4.0で取り除かれる。両方を宣言すると[wcs/declaration-alias]で失敗し、エラーメッセージはいま$renderedCallbackを名指しする。3.1以前では$updatedCallbackと書くこと。4.0: $updatedCallbackを宣言したstateは、diagnostics機能が組み込まれている所(@wcstack/state、/auto)では[wcs/declaration-alias] $updatedCallback was removed: write $renderedCallback.(#1601)で失敗する。それの無い/coreではそのキーは無視される。
$errorCallback(error, info) (v2.2.0) is the in-page error boundary for bindings. When applying a binding throws — a path getter or filter threw, a structural directive failed — the failure is isolated (the rest of the batch still applies) and, without the hook, reported with console.error. Declare it on the root state and the report comes to you instead: info is { path, bindingType, node } with the path as written in data-wcs, this is the writable state proxy, and the hook runs once per failed binding after $renderedCallback, is not awaited, and has its own exceptions isolated. Through 3.x it was root-only: declared on a volume or a mounted component it did not run — ignored silently before v2.6.0, then named by the warning that lists the root-only keys. 4.0: a mounted component’s $errorCallback receives the failures of the component’s own bindings, and a volume’s is still not run (console.warn; see what each scope runs). info.node is null for a list that no for: renders (one only $getAll or $watch keeps), and a binding refused as it is attached inside a for: / if: row — an undeclared token, a member the element does not declare — is reported here as that binding’s failure while the row is still built. DevTools still receives state:binding-apply-error.
$errorCallback(error, info)(v2.2.0)はバインディングのページ内エラー境界。バインディング適用時のthrow — パスgetterやフィルタの例外、構造ディレクティブの失敗 — は隔離され(バッチの残りは適用される)、フックが無ければconsole.errorに報告される。ルートstateに宣言すればその報告が届く: infoは{ path, bindingType, node }でpathはdata-wcsに書かれたまま、thisは書き込み可能なstateプロキシ、失敗したバインディングごとに$renderedCallbackの後に1回走り、awaitされず、自身の例外も隔離される。3.xまではルート専用だった: ボリュームやマウントされたコンポーネントで宣言しても動かず — v2.5.1までは黙って無視され、v2.6.0からはルート専用キーを挙げる警告で名指しされた。4.0: マウントされたコンポーネントの$errorCallbackは、そのコンポーネント自身のバインディングの失敗を受け取り、ボリュームのものは今も動かない(console.warn。スコープごとに何が動くかを参照)。どのfor:も描いていないリスト($getAllや$watchだけが保つもの)ではinfo.nodeはnullで、for: / if:の行の中で取り付けるときに拒まれたバインディング — 宣言していないトークン、要素が宣言していないメンバー — も、そのバインディングの失敗としてここに報告され、行は組み立てられる。DevToolsは変わらずstate:binding-apply-errorを受け取る。
v2.4.0: a root element that fails to initialize no longer hangs the page. A throw from a $ declaration validator, a state source, the enable-ssr merge, DCC or bind-component setup — or from a second root <wcs-state> — is reported once with console.error and rejects connectedCallbackPromise with the original error, so renderToString(), mount() and getBindingsReady(root) fail instead of waiting forever. initializePromise still resolves, only the duplicate root is refused, and setInitialState() on a failed element throws — replace the element. Detaching an element while its source is still loading is not a failure. Re-setting a state object (setInitialState(), or assigning _state again) reads the new generation — and as of v2.5.0 the page is re-drawn with it.
v2.4.0: 初期化に失敗したルート要素はもうページを止めない。$宣言バリデータ・stateソース・enable-ssrのマージ・DCCやbind-componentのセットアップ — あるいは2つ目のルート<wcs-state> — からのthrowはconsole.errorで一度だけ報告され、元のエラーでconnectedCallbackPromiseをrejectする。だからrenderToString()・mount()・getBindingsReady(root)は永久に待たずに失敗する。initializePromiseは引き続きresolveし、拒否されるのは重複したルートだけで、失敗した要素へのsetInitialState()はthrowする — 要素を置き換えること。ソースの読み込み中に要素を外すのは失敗ではない。stateオブジェクトの再セット(setInitialState()、または_stateへの再代入)は新しい世代を読む — そしてv2.5.0からは、ページもそれに合わせて描き直される。
v2.5.0: a re-set re-renders the page. setInitialState(next) on an already-initialized element re-applies every established binding to the new state before it returns — scalars, getters, row getters, for and if alike, structural bindings first and outer before inner, so a binding inside an if that the re-set closes is detached before anything reads it. A binding the new state cannot serve is reported as a failed apply (console.error, or the new state’s $errorCallback) instead of being left showing the old text, and the one-report-per-path ledger behind wcs/binding-path-missing now belongs to a single state generation — a path the new state spells differently is reported again, while a $watch path it no longer declares is not. A re-set is not a write: no $watch handler, no $scan fold, no $stream restart, no $renderedCallback. Lists are matched by array identity, so hand over a new array whenever a list’s length changed; the same instance pushed into or spliced in place is not supported. A detached element re-applies when it reconnects. It still throws on a tree that already has grafted volumes or mounted components, on a mount= volume element, and on an element that failed to initialize.
v2.5.0: 再セットがページを再描画する。初期化済みの要素へのsetInitialState(next)は、戻る前に確立済みのバインディングをすべて新しいstateへ再適用する — スカラーもgetterも行getterも、forもifも同じように。構造バインディングが先で、外側から内側へ。だから再セットが閉じるifの中のバインディングは、誰かが読む前に切り離される。新しいstateが応えられないバインディングは古い文字列を残したままにされず、適用の失敗として報告される(console.error、または新しいstateの$errorCallback)。wcs/binding-path-missingの裏にある「パスごとに1回」の台帳もstateの世代ごとに属するようになった — 新しいstateで綴りの違うパスはもう一度報告され、新しいstateがもう宣言しない$watchのパスは報告されない。再セットは書き込みではない: $watchハンドラも$scanのfoldも$streamの再起動も$renderedCallbackも走らない。リストは配列の同一性で照合されるので、長さが変わったリストには新しい配列を渡すこと。その場でpushやspliceした同じインスタンスはサポート外だ。外れている要素は再接続時に再適用する。すでにボリュームやマウント済みコンポーネントが接ぎ木された木、mount=のボリューム要素、初期化に失敗した要素に対しては、いまもthrowする。
v3.5.0: the v2.4.0 promise holds now. A failing state=, src="*.js", json= or inline source rejected connectedCallbackPromise with a new Failed to initialize state: … Error that kept the original only as text — its type, stack and cause lost — and two loaders and a DCC definition wrapped it once more. The same object now arrives: JSON.parse’s SyntaxError for state= / json=, the import() rejection or the module’s own thrown value for src="*.js", and for an inline script the loader’s own Error, which names the script and keeps the import failure as cause. A volume’s volume "…" failed to load. report carries the same unwrapped error. Code that matched the old text must match the original error instead. The console header names the source — [@wcstack/state] <wcs-state src="./state.js"> failed to initialize. — and two sources that do not fail in 3.x now say so: a src="*.json" that cannot be fetched or parsed logs its URL with the HTTP status or the error (it was Failed to load JSON file:, with no prefix and no URL) and starts empty, and a state="id" with no <script type="application/json"> of that id in the document warns once — the lookup is document.getElementById, so a script inside a shadow root is not found. Both still resolve connectedCallbackPromise (a volume grafts {}); 4.0 rejects both.
v3.5.0: v2.4.0の約束がいまは守られる。失敗したstate=・src="*.js"・json=・インラインのソースは、元のエラーを文字列としてしか残さない新しいFailed to initialize state: …のErrorでconnectedCallbackPromiseをrejectしていた — 型もスタックもcauseも失われ — 2つのローダーとDCCの定義はそれをもう一度包んでいた。いまは同じオブジェクトが届く: state= / json=ならJSON.parseのSyntaxError、src="*.js"ならimport()のrejectかモジュール自身がthrowした値、インラインのスクリプトならローダー自身のErrorで、それはスクリプトを名指しし、importの失敗をcauseとして保つ。ボリュームのvolume "…" failed to load.の報告も同じ包まれていないエラーを運ぶ。古い文字列に合わせていたコードは、代わりに元のエラーに合わせること。コンソールの見出しはソースを名指しし — [@wcstack/state] <wcs-state src="./state.js"> failed to initialize. — 3.xでは失敗にならない2つのソースもいまはそう告げる: 取得も解析もできないsrc="*.json"は、URLをHTTPステータスかエラーとともに記録して(以前は接頭辞もURLも無いFailed to load JSON file:だった)空のstateで始まり、そのidの<script type="application/json">が文書に無いstate="id"は一度だけ警告する — 探すのはdocument.getElementByIdなので、shadow rootの中のスクリプトは見つからない。どちらも今もconnectedCallbackPromiseをresolveし(ボリュームは{}を接ぎ木する)、4.0はどちらもrejectする。
v4.0.0: a failure is reported once, with console.error, naming the element and its source — [@wcstack/state] <wcs-state src="./state.js"> failed to initialize. (#49, naming mount= and bind-component= too) — followed by the error, and connectedCallbackPromise rejects with it, unwrapped. What fails initialization: a source that cannot be read (the two 3.5 exceptions included), an invalid declaration (a removed name, a $behavior / $features / token declaration of the wrong shape, a declaration whose feature is not installed), a second root <wcs-state> on the same root (#47), and a markup error found while the page is bound — a syntax error, an unknown filter or a wrong argument count, a wildcard-rank error, for: with filters, outerHTML: in a template. The walk stops at that error: what comes before it in document order is bound and keeps following writes, what comes after it is not (3.x failed only that binding), so run the 4.0 lint before you deploy. Inside a for: / if: row the same error is that binding’s failure and goes to $errorCallback. A failing $connectedCallback is not an initialization failure: once the bindings are built it is reported as … $connectedCallback failed. (#50), connectedCallbackPromise rejects, and getBindingsReady() resolves, since the page is bound; on a reconnect it is reported the same way, and a root’s $disconnectedCallback that throws or rejects as … $disconnectedCallback failed. (#51) — neither escapes the element’s callback. A DCC definition or a bind-component that fails rejects, and so do a component wired to a root that failed (3.x left it unsettled, so whatever waited hung) and a second <wcs-state bind-component> in one component (3.x resolved it) — and with them renderToString() and mount(). A volume still resolves. An element that failed cannot be re-armed: setInitialState() on it throws (#14).
v4.0.0: 失敗はconsole.errorで一度だけ報告され、要素とそのソースを名指しし — [@wcstack/state] <wcs-state src="./state.js"> failed to initialize.(#49。mount=とbind-component=も名指しする) — その後にエラーが続き、connectedCallbackPromiseは包まれていないそのエラーでrejectする。初期化を失敗させるもの: 読めないソース(3.5の2つの例外も含む)、不正な宣言(取り除かれた名前、形の違う$behavior / $features / トークンの宣言、機能が組み込まれていない宣言)、同じルートの2つ目のルート<wcs-state>(#47)、そしてページをバインドする間に見つかったマークアップのエラー — 構文エラー、未知のフィルタや引数の個数の誤り、wildcard-rankのエラー、フィルタ付きのfor:、テンプレートの中のouterHTML:。走査はそのエラーで止まる: 文書順でそれより前はバインドされて書き込みに追従し、後はされない(3.xはそのバインディングだけを失敗させていた)。だからデプロイの前に4.0のlintを走らせること。for: / if:の行の中では、同じエラーはそのバインディングの失敗として$errorCallbackへ届く。失敗した$connectedCallbackは初期化の失敗ではない: バインディングを組み立てた後のものは… $connectedCallback failed.(#50)として報告され、connectedCallbackPromiseはrejectし、ページはバインド済みなのでgetBindingsReady()はresolveする。再接続時のものも同じに報告され、throwしたりrejectしたりしたルートの$disconnectedCallbackは… $disconnectedCallback failed.(#51)として報告される — どちらも要素のコールバックの外へは漏れない。失敗したDCCの定義やbind-componentはrejectし、失敗したルートに配線されたコンポーネント(3.xは決着させず、待っていたものは止まったままだった)と、1つのコンポーネントの2つ目の<wcs-state bind-component>(3.xはresolveしていた)もrejectする — それとともにrenderToString()とmount()も。ボリュームは今もresolveする。失敗した要素は再び使えない: それへのsetInitialState()はthrowする(#14)。
Computed properties are defined with dot-path string keys, flat at the top level, regardless of how deep the data nests. The wildcard * abstracts the loop index away.
算出プロパティはドットパス文字列のキーで、データの深さに関係なくフラットに定義する。ワイルドカード*がインデックスを抽象化する。
export default {
users: [
{ id: 1, firstName: "Alice", lastName: "Smith" },
{ id: 2, firstName: "Bob", lastName: "Jones" }
],
get "users.*.fullName"() {
return this["users.*.firstName"] + " " + this["users.*.lastName"];
},
// Path setters — split a write back into its parts
set "users.*.fullName"(value) {
const [first, ...rest] = value.split(" ");
this["users.*.firstName"] = first;
this["users.*.lastName"] = rest.join(" ");
},
// Top-level aggregation over a wildcard
get totalUsers() {
return this.$getAll("users.*.id", []).length;
}
};
get "categories.*.items.*.label"() {
return this["categories.*.name"] + " / " + this["categories.*.items.*.name"];
}
$recursion)A path burns its depth into the string, but a tree’s depth belongs to the data. v2.3.0: declare where the shape repeats — an anchor and the sub-path that descends one level — and ** stands for “however deep this is”. One getter then covers every depth, and each depth’s accessor is materialized the first time it is read.
パスは深さを文字列に焼き付けるが、木の深さはデータに属する。v2.3.0: 形が繰り返される場所 — アンカーと1段降りる部分パス — を宣言すれば、**が「ここが何段であろうと」を表す。getter 1つがすべての深さを覆い、各深さのアクセサは最初に読まれたときに実体化される。
export default {
$recursion: { "nodes.*": "children.*" }, // anchor → repeating sub-path
nodes: [ { value: 1, children: [ { value: 10, children: [] } ] } ],
// `**` is bound to the depth being evaluated; the index-omitted
// $getAll reads exactly the direct children of that node.
get "nodes.**.total"() {
return this["nodes.**.value"]
+ this.$getAll("nodes.**.children.*.total").reduce((a, b) => a + b, 0);
},
// `[]` unions every depth — depth-first, pre-order, ascending index
get treeTotal() {
return this.$getAll("nodes.**.value", []).reduce((a, b) => a + b, 0);
},
clearSelection() {
this.$setAll("nodes.**.selected", [], false); // broadcast to every node
}
};
Bound or unioned is decided by context, the same split * has between “the current row” and “every row”: a getter key and reads inside it are bound to the depth being evaluated, $getAll with indexes omitted stays at that depth, and $getAll(path, []) unions every depth. The omitted form is what makes an aggregate fold its own subtree exactly once; unioning an aggregate from outside double-counts, and that is not decidable from the path string, so no diagnostic claims to catch it. Bound reads need a depth, so they resolve only inside a recursive getter, a row getter under the anchor, or a handler bound to such a row — elsewhere it is wcs/recursion-context.
束縛か連結かは文脈が決める。*の「現在の行」と「すべての行」と同じ対比だ: getterのキーとその内側の読みは評価中の深さに束縛され、インデックスを省いた$getAllはその深さに留まり、$getAll(path, [])は全深さを連結する。集計が自分の部分木をちょうど1回だけ畳み込むのは省略形のおかげ。外から集計値を連結すると二重計上になるが、これはパス文字列からは決定できないので、捕まえると称する診断は無い。束縛形の読みは深さを必要とするため、再帰getter・アンカー配下の行getter・その行に束縛されたハンドラの中でのみ解決する。それ以外ではwcs/recursion-context。
** is authoring notation only — PathInfo, the dependency graph, $1…$n, $resolve and the list diff only ever see ordinary fixed-arity paths. What cannot be expressed that way is refused rather than reinterpreted, under the wcs/recursion-* codes the linter shares: a recursive getter is read-only (write what it derives from), a broadcast at the structure itself is refused, ** in markup / $watch / $listKeys / $resolve is wcs/recursion-unsupported, and this version takes exactly one self-recursive anchor per state, a tree as input (a shared child array or a cycle is named) and 128 wildcard levels. $recursion and ** getters are root-only. Rendering a tree stays with a self-referential component: ** cannot appear in markup, and inside each scope only one level of path is ever used.
**は記述上の記法にすぎない — PathInfo・依存グラフ・$1…$n・$resolve・リスト差分が見るのは、常に固定アリティの普通のパスだ。そう表現できないものは再解釈されずに拒まれる。コードはlinterと共通のwcs/recursion-*: 再帰getterは読み取り専用(導出元に書くこと)、構造そのものへのブロードキャストは拒否、マークアップ・$watch・$listKeys・$resolve中の**はwcs/recursion-unsupported。このバージョンが受け付けるのはstateごとに自己再帰アンカー1つ、入力は木(子配列の共有や循環は名指しされる)、ワイルドカードは128段まで。$recursionと**getterはルート専用。木の描画は従来どおり自己参照コンポーネントが担う: **はマークアップに書けず、各スコープの中で使うパスは常に1段だけだ。
v2.4.0: replacing row objects while keeping their children arrays — this.nodes = this.nodes.map(n => ({ ...n })) — no longer freezes that row’s nodes.*.total (on 2.3.0 the leaf, the deeper totals and the [] unions stayed right and only that one number stopped, with no diagnostic). The child list’s rows are re-pointed at the live row instead of rebuilt, so a bind-component child scope keeps its rendered rows — and an open <details>, focus or a JS marker with them. Keep one children array per node when a child getter reads upward: an array has exactly one set of rows.
v2.4.0: children配列を保ったまま行オブジェクトを置き換える — this.nodes = this.nodes.map(n => ({ ...n })) — と、その行のnodes.*.totalが凍る問題は無くなった(2.3.0では葉・より深いtotal・[]連結は正しいまま、その1つの数だけが診断なしに止まった)。子リストの行は作り直されずに生きている行へ付け替えられるので、bind-componentの子スコープは描いた行を保つ — 開いた<details>・フォーカス・JSのマーカーもそのまま。子のgetterが上を読むなら、ノードごとにchildren配列を持たせること: 配列が持つ行はちょうど1組だ。
| API | Purpose |
|---|---|
this.$getAll("path.*", []) | Collect all values for a wildcard path |
this.$resolve("path.*", [i], value?) | Read / write at an explicit index. 3.0: the argument count decides — two arguments read, three write (undefined included); a write through a readonly proxy throws This state is readonly., as $setAll does. 4.0: writing an element replaces the value at that position (the row stays); an index past the end reads undefined, and a write there throws no row for "items.*" (#3) and changes nothing |
this.$setAll("path.*", [], value) | v1.32.0: write every matching address in place — broadcast, mapper (cur, ...i) => next, or per-address array with { spread: true }. Keeps the array (list indexes, row caches, render diff survive, unlike .map() reassignment); undefined means “skip this address”, null clears |
this.$postUpdate("path") | Manually trigger an update notification |
this.$dependOn(path) | Register a dependency for cache invalidation. v3.2.0 name; the old $trackDependency worked the same through 3.x (3.5 warned once) and is removed in 4.0: reading it throws [wcs/name-alias] $trackDependency was removed: write $dependOn. (#1701) with the diagnostics feature, and gives undefined on /core without it |
this.$untracked(fn) | Read values without registering dependencies. v3.2.0 name; the old $untrackDependency worked the same through 3.x (the pair read as inverses of each other, and they are not) and is removed in 4.0, like $trackDependency |
this.$eq(path, key), this.$eqPath(path, keyPath), this.$eqIndex(path, level?) | v2.6.0: keyed subscription — “is path this row’s key?” without every row depending on path (see Keyed selection) |
this.$1, this.$2, … | Loop iteration index (0-based value, 1-based naming) |
Since v1.31.0 index arity is checked: $resolve requires the index count to match the path’s * count exactly, and $getAll treats it as an upper bound (fewer is a legitimate prefix meaning “expand the rest”). Surplus indexes used to be silently dropped, returning a plausible-looking wrong value; both now throw wcs/index-arity — the same code the linter and the IDE report statically, alongside wcs/wildcard-rank (a path’s * count or $N exceeding the enclosing for nesting) and wcs/getter-cycle (which names the getters in the cycle).
v1.31.0からインデックスのアリティが検査される。$resolveはインデックス数がパスの*数と完全一致することを要求し、$getAllは上限として扱う(少ない分は「残りを展開」という正当なプレフィクス)。余分なインデックスは以前は黙って捨てられ、もっともらしい間違った値が返っていた。いまはどちらもwcs/index-arityをthrowする — linterとIDEが静的に報告するのと同じコードで、wcs/wildcard-rank(パスの*数や$Nが囲むforの深さを超える)と、サイクル中のgetter名を挙げるwcs/getter-cycleも並ぶ。
Numeric-index paths (v3.4.0). this["items.0.v"] reads and writes the row at that index without loop context, whether or not a for: renders the list. Through 3.3 it worked only on a list something had already diffed — a for:, a $getAll, a dependency walk — and elsewhere the read and the write both threw ListIndex not found: items; a cold $resolve(path, indexes) and $postUpdate("items.0.v") no longer need the list warmed first either. An index with no row throws ListIndex not found at index <i> of <list>. Writing a different object to an element path (this["items.0"] = { ...this["items.0"], name: "z" }, or through $resolve / $setAll) makes that position a new row, so every read below it — items.0.name, $getAll("items.*.name", []), row getters — sees the new object; without a for: they used to keep the replaced object’s values. Writing the same object back after changing it in place, or announcing it with $postUpdate("items.0") / $postUpdate("items"), keeps the row and re-reads the paths below it. One object at two positions of a list ([o, o]) is still two rows: a write below one is not seen by the other until $postUpdate("items"). A component in a for: row can use the same spelling in its own vocabulary (this["drafts.1.t"]), resolved against its host row. Bindings can spell an index too — see Binding.
数値インデックスのパス(v3.4.0)。 this["items.0.v"]は、ループの文脈なしにそのインデックスの行を読み書きする — for:がリストを描いていてもいなくても。3.3までは、何かがすでに差分を取ったリスト — for:、$getAll、依存の走査 — でしか動かず、それ以外では読みも書き込みもListIndex not found: itemsをthrowした。冷えたリストへの$resolve(path, indexes)や$postUpdate("items.0.v")も、先にリストを温めておく必要はもう無い。行の無いインデックスはListIndex not found at index <i> of <list>をthrowする。要素のパスへ別のオブジェクトを書く(this["items.0"] = { ...this["items.0"], name: "z" }、あるいは$resolve / $setAllを通して)と、その位置は新しい行になるので、その下の読み — items.0.name、$getAll("items.*.name", [])、行getter — はどれも新しいオブジェクトを見る。for:が無いと、以前は置き換えられたオブジェクトの値を保っていた。その場で変更した同じオブジェクトを書き戻すか、$postUpdate("items.0") / $postUpdate("items")で知らせれば、行は保たれ、その下のパスが読み直される。リストの2つの位置にある1つのオブジェクト([o, o])は今も2つの行だ: 片方の下への書き込みは、$postUpdate("items")するまでもう片方には見えない。for:の行の中のコンポーネントは、自分の語彙で同じ綴りを使え(this["drafts.1.t"])、ホストの行に対して解決される。バインディングもインデックスを綴れる — Bindingを参照。
v4.0.0: writing an element (this["items.0"] = { ...this["items.0"], name: "z" }) replaces the value at that position: the row stays where it is, with its $1, takes the new value, and everything derived below it is computed again, whether or not a for: renders the list. An index past the end of a list reads undefined, and a write there throws no row for "items.*.v" (#3) and changes nothing (3.x threw ListIndex not found on both). A numeric segment is an index only where the value before it is an array: under any other object it is a key, spelled as written — sales.2024.total and usersById.42.name read and write sales["2024"].total and usersById["42"].name in markup, through this[…], $eq, $resolve / $setAll / $getAll, a two-way write-back and a $watch key alike, and byDay.007 reads the key "007" (#382). With for: groups.0.items, or a getter returning that array, this["groups.0.items.1.v"] reads and writes instead of throwing [wcs/wildcard-rank] (#363), and a row-getter path that mixes * and an index (groups.*.sel.0.id) reads and follows writes (#383). One object at two positions of one list is still two rows — a write below one does not reach the other’s bindings until $postUpdate("items") — and so are two arrays at plain keys that share an object; that limitation is to be fixed in 4.0.x.
v4.0.0: 要素への書き込み(this["items.0"] = { ...this["items.0"], name: "z" })はその位置の値を置き換える: 行は$1ごとその場にとどまって新しい値を受け取り、その下で導かれるものは、for:がリストを描いていてもいなくても、すべて計算し直される。リストの末尾を越えたインデックスの読みはundefinedで、そこへの書き込みはno row for "items.*.v"(#3)をthrowして何も変えない(3.xはどちらもListIndex not foundをthrowしていた)。数値の区間が添字になるのは、その前の値が配列のときだけだ: それ以外のオブジェクトの下では書いたとおりのキーで — sales.2024.totalやusersById.42.nameはsales["2024"].totalやusersById["42"].nameを読み書きし、マークアップでもthis[…]でも$eqでも$resolve / $setAll / $getAllでも、双方向の書き戻しでも$watchのキーでも同じだ。byDay.007はキー"007"を読む(#382)。for: groups.0.items(あるいはその配列を返すgetter)では、this["groups.0.items.1.v"]は[wcs/wildcard-rank]をthrowせずに読み書きし(#363)、*と添字の混ざった行getterのパス(groups.*.sel.0.id)も読めて書き込みに追従する(#383)。1つのリストの2つの位置にある1つのオブジェクトは今も2つの行だ — 片方の下への書き込みは、$postUpdate("items")するまでもう片方のバインディングに届かない — オブジェクトを共有する素のキーの2つの配列も同じで、この制限は4.0.xで直される予定だ。
A getter’s cache is invalidated only through the dependency graph, and the graph records only what was read through this. A getter reading Date.now(), the DOM, or a module variable therefore keeps its first value forever — with no warning. Read only through this; when an untracked input must participate, put it into state and assign to it, or use $dependOn / $postUpdate / $untracked. Getters that throw are not swallowed: the exception surfaces where the getter was evaluated.
getterのキャッシュは依存グラフ経由でのみ無効化され、グラフはthis越しの読み取りしか記録しない。Date.now()・DOM・モジュール変数を読むgetterは、警告なしに最初の値を永遠に保持する。読むのはthis越しだけにする。追跡外の入力を関与させたいときは、stateに入れて代入するか、$dependOn / $postUpdate / $untrackedを使う。throwするgetterは握りつぶされず、評価された場所で例外が表面化する。
$eq / $eqPath / $eqIndex)A row getter that answers “is this row the selected one?” is where dependency tracking scales worst: get "items.*.selected"() { return this.$1 === this.selectedIndex; } makes every row depend on selectedIndex, so one click re-evaluates 10,000 getters. v2.6.0: the keyed forms subscribe each row under its own key instead, and a write to the path re-evaluates only the row that was selected and the row that becomes selected — selecting one of 10,000 rows goes from 20 ms to 0.2 ms.
「この行は選択中か」を答える行getterは、依存追跡が最も効きにくい形だ: get "items.*.selected"() { return this.$1 === this.selectedIndex; }は全行をselectedIndexに依存させるので、1クリックで1万個のgetterが再評価される。v2.6.0: 鍵付きの形は各行をその行自身の鍵の下に購読させ、パスへの書き込みで再評価するのは選択されていた行と選択される行だけにする — 1万行から1行を選ぶ操作は20 msから0.2 msになった。
export default {
items: [],
selectedId: null,
selectedIndex: null,
// selection by id: survives sorting and removal
get "items.*.selected"() { return this.$eqPath("selectedId", "items.*.id"); },
// selection by position: the same row count of work, keyed on the index
get "items.*.current"() { return this.$eqIndex("selectedIndex"); },
onSelect(e, $1) { this.selectedId = this["items." + $1 + ".id"]; this.selectedIndex = $1; },
};
| API | Key | Selection follows | Notes |
|---|---|---|---|
this.$eq(path, key) | any value | the key | path is read without a dependency; the row registers under key |
this.$eqPath(path, keyPath) | the value at keyPath (wildcards resolve to this row) | the id | reads the key without a dependency too, so replacing or reordering the list never re-evaluates the rows |
this.$eqIndex(path, level = 1) | this row’s index ($1; level picks the wildcard) | the index | unlike reading $1, the getter is not recorded as index-dependent: the list diff re-keys moved rows, so removing a row re-evaluates at most two rows |
What reaches the rows. On 2.6.0 the first three could leave a row selected, or never select it, without an error; v2.6.1 closed all three.
path — of any value, objects included (this.selected = row with $eq("selected", this["items.*"])): the row that was selected and the row that becomes selected. The rows are keyed by the value they last saw, so an object write, or any write with config.sameValueGuard off, finds the previous row too.path — this.sel = { id: 2 } for $eq("sel.id", …): the same two rows, keyed by the value path had under the old object and has under the new one.path that is a getter, or sits under one ($eq("current.id", …) with get current()): its value changes without a write to it, so the calls fall back to an ordinary tracked read. The selection stays correct, but a change re-evaluates every row, as without the keyed form — point path at the written state (selectedId) to keep the two-row cost.Map keys (Object.is, with +0 / -0 and NaN / NaN equal), so "2" does not match the id 2. An <input> or <select> writes strings: convert on the way in (value|number: selectedId) or keep the ids as strings.行に届くもの。2.6.0では最初の3つがエラーなしに行を選択したまま残すか、一度も選択しないことがあった — v2.6.1で3つとも塞がれた。pathへの書き込みは、オブジェクトを含むどんな値でも($eq("selected", this["items.*"])でthis.selected = row)選択されていた行と選択される行に届く。行は最後に見た値で鍵付けされるので、オブジェクトの書き込みでも、config.sameValueGuardを切った書き込みでも前の行が見つかる。pathの上のオブジェクトへの書き込み($eq("sel.id", …)に対するthis.sel = { id: 2 })も同じ2行に届き、鍵は古いオブジェクトでのpathの値と新しいオブジェクトでの値だ。getterであるpath、またはgetterの下にあるpath(get current()がある$eq("current.id", …))は、書き込み無しに値が変わるので、普通の追跡付きの読みに戻る。選択は正しいままだが、変化のたびに鍵付きの形を使わないときと同じく全行が再評価される — 2行分のコストを保つには、pathを書き込む先のstate(selectedId)に向ける。型変換はしない: 鍵はMapの鍵として(Object.is、ただし+0 / -0とNaN同士は等しい)比較されるので、"2"はid 2に一致しない。<input>や<select>は文字列を書くので、入口で変換する(value|number: selectedId)か、idを文字列のまま持つ。
The calls subscribe only while a getter is being evaluated — from a method or a handler $eq / $eqPath just return the comparison — and $eqIndex takes its key from a list row, so outside one it throws. A row’s subscription is dropped when the list diff removes the row, and a key no row holds any more leaves the ledger with it, so a removed row object is not retained (v2.6.1). $eqPath reads the key without a dependency, so a row whose key changes in place is not re-evaluated by that change: use it for identities that do not change, and $eq with a tracked read when the key itself is live. 3.0: the State pane of @wcstack/devtools lists the subscriptions per path under Keyed selection and marks a path that fell back to a tracked read with a tracked badge — the one case above that costs every row.
購読が張られるのはgetterの評価中だけで — メソッドやハンドラからの$eq / $eqPathは比較結果を返すだけ — $eqIndexは鍵をリスト行から取るので、行の外ではthrowする。行の購読はリスト差分がその行を外した時点で落ち、どの行も持たなくなった鍵も台帳から一緒に消えるので、取り除いた行オブジェクトが保持され続けることはない(v2.6.1)。$eqPathは鍵を依存なしで読むので、鍵がその場で書き換わった行はその変更では再評価されない: 変わらない同一性(id)に使い、鍵そのものが動くなら追跡付きの読みと$eqを組み合わせる。3.0: @wcstack/devtoolsのStateペインはKeyed selectionの下にパスごとの購読を並べ、追跡付きの読みに戻ったパスにtrackedバッジを付ける — 上のうち全行ぶんのコストがかかる唯一の場合だ。
v3.3.0: inside a mounted component or a volume, $eq / $eqPath / $eqIndex resolve against that scope’s own paths ($eqPath translates both of its paths). They used to read the root’s, so this.$eq("selectedId", 2) inside <wcs-state mount="cart"> compared against the root’s selectedId — a silent false, or a throw naming a path the author never wrote. A getter derived from a keyed getter (get label() { return this.picked; } over get picked() { return this.$eq(…); }) no longer keeps its old value, also after a list diff re-keys $eqIndex rows, and $postUpdate after an in-place mutation notifies the keyed rows. A selection change at 10,000 rows still re-evaluates about two getters. Retiring a parent row of a nested list now drops its children’s subscriptions, so the DevTools count no longer grows past the rows on the page.
v3.3.0: マウントされたコンポーネントやボリュームの中では、$eq / $eqPath / $eqIndexはそのスコープ自身のパスで解決される($eqPathは2つのパスの両方を翻訳する)。以前はルートのパスを読んでいたので、<wcs-state mount="cart">の中のthis.$eq("selectedId", 2)はルートのselectedIdと比べていた — 黙ってfalseを返すか、作者が書いてもいないパスを名指ししてthrowした。鍵付きgetterから導いたgetter(get picked() { return this.$eq(…); }の上のget label() { return this.picked; })はもう古い値を保たず、リスト差分が$eqIndexの行の鍵を付け替えた後も同じだ。その場の変更の後の$postUpdateも鍵付きの行に届く。1万行での選択の変更が再評価するgetterは今も約2個だ。ネストしたリストの親の行を退けると子の購読も落ちるので、DevToolsの件数がページ上の行数を超えて育つことはもう無い。
v3.4.0: a path with a numeric index ($eq("items.0.v", "b")) falls back to a tracked read, as a getter path does. A write reports the index as a wildcard (items.*.v, items.*), so a keyed subscription under the spelled path never heard it: the getter kept its first value through this["items.0.v"] = "b" and this["items.0"] = { v: "b" } and followed only a replacement of the list. It now follows all three, at the cost of re-evaluating on a write to any row’s v, and DevTools marks the path tracked. A $eqIndex that falls back this way records the row index as read, so a row that only moves (a reorder, an insert above it) is re-evaluated. 4.0: $eq / $eqPath key only paths outside lists, so a wildcard path ($eq("items.*.v", …)) falls back the same way, and follows a write to that row’s v, a replacement of the element and of the list.
v3.4.0: 数値インデックスを持つpath($eq("items.0.v", "b"))は、getterのパスと同じく追跡付きの読みに戻る。書き込みはインデックスをワイルドカードとして報告する(items.*.v、items.*)ので、綴ったとおりのパスの下の鍵付きの購読には届かなかった: getterはthis["items.0.v"] = "b"やthis["items.0"] = { v: "b" }を経ても最初の値を保ち、追従したのはリストの置き換えだけだった。いまは3つすべてに追従し、その代わりにどの行のvへの書き込みでも再評価され、DevToolsはそのパスにtrackedを付ける。こうして戻った$eqIndexは行のインデックスを読んだものとして記録するので、移動しただけの行(並べ替え、上への挿入)も再評価される。4.0: $eq / $eqPathが鍵付けするのはリストの外のパスだけなので、ワイルドカードのpath($eq("items.*.v", …))も同じように戻り、その行のvへの書き込み、要素の置き換え、リストの置き換えに追従する。
Getters are lazy, and exactly three things create demand: a live DOM binding (demand disappears with the element), a $watch declaration (headless), and a $stream args function. $renderedCallback is not a root — it reports what the bindings did. Logic that must not depend on what is rendered belongs on $watch or args; $renderedCallback testing a path nothing binds is detected statically as wcs/updated-callback-unbound (v1.31.0).
getterは遅延評価で、需要を生むものはちょうど3つ。ライブなDOMバインディング(要素が消えれば需要も消える)、$watch宣言(ヘッドレス)、$streamのargs関数。$renderedCallbackはrootではない — バインディングが行ったことを報告するだけだ。描画に依存してはならないロジックは$watchかargsに置く。何もバインドしていないパスを$renderedCallbackで調べる形はwcs/updated-callback-unboundとして静的に検出される(v1.31.0)。
data-wcs #General form — multiple bindings separated by ;:
property[#modifier]: path[|filter[|filter(args)...]]
<div data-wcs="textContent: count; class.over: count|gt(10)"></div>
The separators ; and | split only outside quotes (3.0), so a quoted filter argument may contain them: textContent: tags|join('; '), title: parts|join(' | '). Before 3.0 both broke the binding.
区切りの;と|は引用符の外でだけ区切る(3.0)ので、引用符付きのフィルタ引数はそれらを含められる: textContent: tags|join('; ')、title: parts|join(' | ')。3.0より前はどちらもバインディングを壊していた。
v3.3.0: the : between the two sides is found outside quotes too (value|defaults(':'): path used to throw), and so is the one inside a for row — where a quoted :, value|defaults('00:00'): .startTime, used to leave the shorthand unexpanded and render zero rows, silently, while lint and the editor stayed green. The parser now refuses with [wcs/binding-syntax] what used to fail only when applied, or not at all: an empty path segment on the right (a., a..b, an empty right side — a leading dot is still the loop shorthand), text after a filter’s closing ) (n|toFixed(2)upper silently dropped upper), a left side that names no property (: x, #ro: x), and a path of more than 512 segments (one deep path could exhaust the heap). Syntax errors that had no code — a missing :, an unmatched parenthesis — now carry [wcs/binding-syntax], so the VS Code extension shows them.
v3.3.0: 両辺を分ける:も引用符の外で探されるようになり(value|defaults(':'): pathはthrowしていた)、forの行の中でも同じだ — 行の中では引用符付きの:、value|defaults('00:00'): .startTimeが省略記法を展開させず、黙って1行も描画しなかった。lintとエディタは緑のままだった。パーサはいま、適用時にしか失敗しなかった、あるいはまったく失敗しなかったものを[wcs/binding-syntax]で拒む: 右辺の空のパス区間(a.・a..b・空の右辺 — 先頭のドットは今もループの省略記法)、フィルタの閉じ)の後ろの文字(n|toFixed(2)upperは黙ってupperを落としていた)、プロパティを名指ししない左辺(: x・#ro: x)、512区間を超えるパス(深いパス1つでヒープを使い果たせた)。コードの無かった構文エラー — :の欠落、対応しない括弧 — も[wcs/binding-syntax]を運ぶようになり、VS Code拡張に表示される。
One numeric index (v3.4.0). A binding path with exactly one numeric index — textContent: items.0.v, {{ users.1.name }}, a row getter {{ users.0.fullName }} — means what this["users.0.name"] means in code: whatever row is at index 0 now. Through 3.3 it was a plain key path: it kept its first value through an index-path write, a for: row input, an element replacement or a reorder, followed only a replacement of the whole list, and a row getter spelled that way rendered empty with wcs/binding-path-missing. It is now read like a getter over the row, through a non-enumerable accessor on the state object (Object.keys, JSON.stringify and the SSR snapshot do not see it), with or without a for:. Like a getter it is re-evaluated when any row of the list is written, and a $watch on "items.0.v" can fire with an unchanged value; in happy-dom one batch of 1,000 index-path writes to a 1,000-row list took 4–5 ms with no such binding, about 87 ms with 100 and 180–220 ms with 300. Three cases keep the 3.3 reading: two or more numeric indexes (groups.0.items.1.v), a state that cannot be extended (Object.freeze / seal / preventExtensions), and a key the state declares under that very name. A numeric key under an object (sales.2024.total) is still a plain key. Lint and the VS Code extension judge paths the same way: items.0.v and items.0.double are no longer wcs/binding-path-missing, a typo below a row (items.0.nmae) still is — and with a stateSchema it is now wcs/path-nonexistent — and for: groups.0.items is now wcs/template-syntax: it renders, but a two-way binding in its rows throws Partial wildcard type is not supported yet, so nest for: .items inside for: groups.
数値インデックス1つ(v3.4.0)。 数値インデックスをちょうど1つ持つバインディングのパス — textContent: items.0.v、{{ users.1.name }}、行getterの{{ users.0.fullName }} — は、コードのthis["users.0.name"]と同じこと、つまりいまインデックス0にある行を意味する。3.3までは素のキーのパスだった: インデックスのパスへの書き込み、for:の行の入力、要素の置き換え、並べ替えを経ても最初の値を保ち、追従したのはリスト全体の置き換えだけで、そう綴った行getterはwcs/binding-path-missingとともに空を描いた。いまはfor:の有無にかかわらず、stateオブジェクトに足される列挙不可のアクセサを通して、その行に対するgetterのように読まれる(Object.keys・JSON.stringify・SSRのスナップショットには見えない)。getterと同じく、リストのどの行への書き込みでも再評価され、"items.0.v"への$watchは値が変わらなくても発火し得る。happy-domでは、1,000行のリストへのインデックスのパスへの書き込み1,000回の1バッチが、そうしたバインディングが無いと4–5 ms、100個で約87 ms、300個で180–220 msだった。3つの場合は3.3の読み方のままだ: 数値インデックスが2つ以上(groups.0.items.1.v)、拡張できないstate(Object.freeze / seal / preventExtensions)、そしてstateがまさにその名前で宣言したキー。オブジェクトの下の数値のキー(sales.2024.total)は今も素のキーだ。lintとVS Code拡張も同じようにパスを判定する: items.0.vとitems.0.doubleはもうwcs/binding-path-missingではなく、行の下のタイプミス(items.0.nmae)は今もそうで — stateSchemaがあればいまはwcs/path-nonexistentになる — for: groups.0.itemsはいまwcs/template-syntaxだ: 描画はされるが、その行の中の双方向バインディングはPartial wildcard type is not supported yetをthrowするので、for: groupsの中にfor: .itemsを入れ子にすること。
v4.0.0: a path with any number of numeric indexes means whatever row is at that index now — groups.0.items.1.v included — with no accessor added to the state object, and follows writes through an index path, through a for: row or an input bound to that row, element replacement, reordering, removals and list replacement. A $watch key with a numeric index fires only when the value at that index changes. for: groups.0.items renders rows that take two-way writes (#363), and the 4.0 lint no longer reports it. The happy-dom costs and the three exceptions above are 3.x’s.
v4.0.0: 数値の添字をいくつ持つパスでもいまその添字にある行を意味し — groups.0.items.1.vも含めて — stateオブジェクトにアクセサを足すことはもう無い。添字のパスへの書き込み、for:の行やその行にバインドした入力欄を通した書き込み、要素の置き換え、並べ替え、削除、リストの置き換えに追従する。数値の添字を持つ$watchのキーは、その添字の値が変わったときだけ発火する。for: groups.0.itemsは双方向の書き込みを受ける行を描き(#363)、4.0のlintはもうそれを報告しない。上のhappy-domでのコストと3つの例外は3.xのものだ。
| Property | Behavior |
|---|---|
value | Two-way for <input>, <select>, <textarea> |
checked | Two-way for checkbox / radio |
textContent / text | DOM text |
html | innerHTML |
class.NAME | CSS class toggle |
style.PROP | CSS property |
attr.NAME | HTML attribute (SVG-safe) |
radio | Radio group binding |
checkbox | Checkbox array binding |
onclick, on* | Event handlers |
.NAME | Explicit property (v3.1.0): never an event, even when the name starts with on |
Properties whose names start with on (v3.1.0). A binding whose name starts with on is an event binding, so online: x listens for a "line" event and never writes the element’s online property. Put a dot in front of the name to bind the property: <my-status data-wcs=".online: isOnline; onclick: refresh">. The dotted form is the same binding as the undotted one except that it is never an event — .value: is two-way like value:, and modifiers and input filters work as usual. A namespace word (.class, .attr, .style, .command, .eventToken) or an empty name after the dot is rejected with [wcs/binding-syntax]. The VS Code extension flags a tag member starting with on bound without the dot (once on <wcs-timer>) as wcs/on-prefixed-member.
onで始まる名前のプロパティ(v3.1.0)。 名前がonで始まるバインディングはイベントバインディングなので、online: xは"line"イベントを待ち受け、要素のonlineプロパティには決して書かない。プロパティとしてバインドするには名前の前にドットを置く: <my-status data-wcs=".online: isOnline; onclick: refresh">。ドット付きの形は、決してイベントにならないことを除けばドットの無い形と同じバインディングだ — .value:はvalue:と同じく双方向で、修飾子も入力フィルタもいつもどおり効く。ドットの後ろの名前空間の語(.class・.attr・.style・.command・.eventToken)や空の名前は[wcs/binding-syntax]で拒まれる。VS Code拡張は、onで始まるタグのメンバーをドット無しでバインドした箇所(<wcs-timer>のonce)をwcs/on-prefixed-memberとして指摘する。
v3.3.0: .online: no longer attaches a "line" listener. Whether a binding listens was decided by the spelling of its name, not by its kind, so on 3.1 and 3.2 the dotted form still subscribed to the event and skipped the two-way wiring behind it — and once wired through a spread took the same path. .state: and .state.<key>: join the rejected namespace words.
v3.3.0: .online:が"line"のリスナーを付けることはもう無い。バインディングが待ち受けるかどうかは種類ではなく名前の綴りで決まっていたので、3.1と3.2ではドット付きの形もイベントを購読し、その後ろの双方向の配線を飛ばしていた — spreadで配線したonceも同じ経路をたどった。.state:と.state.<key>:も拒まれる名前空間の語に加わる。
No value on a display surface (3.0). textContent / text / html, mustache text, attr.* and style.* have no element default worth keeping, so undefined and null both mean “no value” there: the text becomes empty and the attribute or the style is removed. Element inputs — every other property, spread included — still skip undefined and clear on null. Before 3.0 an undefined text kept what was shown, which in a reused for row was the previous row’s text, and an attribute got the string "undefined" / "null".
表示面の「値なし」(3.0)。 textContent / text / html、mustacheのテキスト、attr.*、style.*には保つべき要素の既定値が無いので、そこではundefinedもnullも「値なし」を意味する: テキストは空になり、属性やスタイルは取り除かれる。要素の入力 — それ以外のすべてのプロパティ、spreadを含む — は今もundefinedを飛ばし、nullでクリアする。3.0より前はundefinedのテキストが表示中のものを保ち、使い回されたforの行ではそれが前の行のテキストになり、属性には文字列"undefined" / "null"が入っていた。
v3.3.0: the rule holds through filters. The formatting filters (upper, trim, padStart, truncate, …) pass undefined / null through, so textContent: x|upper no longer renders UNDEFINED and attr.title: x|trim removes the attribute, as the unfiltered form does. class.NAME: treats undefined / null as “remove the class” instead of throwing and taking the row down (any other non-boolean still throws — coerce with |truthy), and an attribute is written only when its string changes.
v3.3.0: この規則はフィルタを通っても保たれる。書式フィルタ(upper・trim・padStart・truncateなど)はundefined / nullを素通しするので、textContent: x|upperがUNDEFINEDと描くことはもう無く、attr.title: x|trimはフィルタ無しの形と同じく属性を取り除く。class.NAME:はundefined / nullを、throwして行ごと倒す代わりに「クラスを外す」として扱う(それ以外の真偽値でない値は今もthrowする — |truthyで変換すること)。属性は文字列が変わったときだけ書かれる。
<input data-wcs="value#ro: path"> <!-- read-only, disables two-way -->
<button data-wcs="onclick#prevent: handler"> <!-- preventDefault() -->
<div data-wcs="onclick#stop: handler"> <!-- stopPropagation() -->
<button data-wcs="onclick#direct: handler"> <!-- 4.0: listen on the element, not the root -->
<select data-wcs="value#onchange: path"> <!-- use 'change' event -->
<my-el data-wcs="value#init=element: path"> <!-- element-owned authority -->
<my-el data-wcs="value#sync=connect: path"> <!-- defer snapshot until DOM connect -->
Two-way binding is automatic for <input> (all types), <select>, <textarea>, checkbox and radio. Use #ro to disable it, #onchange to switch the event.
双方向バインディングはフォーム要素で自動。#roで無効化、#onchangeでイベント切替。
Multiple modifiers are comma-separated after a single #: value#ro,init=none: path. A modifier never changes the kind of binding, so radio#ro: and checkbox#ro: stay radio / checkbox bindings (3.0; before, a modifier made them a plain property named radio and #ro did not take effect). As of 3.0 the parser rejects what it used to round off silently, with [wcs/binding-syntax]: a second # (value#ro#wo kept ro — write value#ro,wo), a value after else:, modifiers or left-side filters on for / if / elseif / else / ..., an unterminated quote in filter arguments and an empty filter. 4.0 adds output filters on for: (#121, see Loop) and a path segment __proto__ or prototype (#120 — refused in writes, $resolve, $setAll and reads such as this.__proto__ too). 4.0.1 adds class: and attr: without a name (#122 — write class.NAME: / attr.NAME:, or className: for the whole attribute; a bare one set an element property named class, which did nothing). A modifier the binding does not use is ignored (#direct on a property binding, an unknown #foo; the lint warns about the first), and an unknown key=value modifier (#foo=1) throws.
複数の修飾子は1つの#の後にカンマで区切る: value#ro,init=none: path。修飾子はバインディングの種類を変えないので、radio#ro:とcheckbox#ro:はradio / checkboxバインディングのままだ(3.0。以前は修飾子が付くとradioという名前の普通のプロパティになり、#roは効かなかった)。3.0からパーサは、これまで黙って丸めていたものを[wcs/binding-syntax]で拒む: 2つ目の#(value#ro#woはroだけを残していた — value#ro,woと書く)、else:の後ろの値、for / if / elseif / else / ...に付いた修飾子や左辺のフィルタ、フィルタ引数の閉じていない引用符、空のフィルタ。4.0は、for:の出力フィルタ(#121。Loopを参照)と、パスの区間__proto__ / prototype(#120 — 書き込み・$resolve・$setAll・this.__proto__のような読みでも拒まれる)を加える。4.0.1は、名前の無いclass:とattr:を加える(#122 — class.NAME: / attr.NAME:と書く。属性全体ならclassName:。名前の無いものは要素のclassというプロパティに書くだけで、何も起きなかった)。バインディングが使わない修飾子は無視され(プロパティバインディングの#direct、未知の#foo。前者はlintが警告する)、未知のkey=valueの修飾子(#foo=1)はthrowする。
{{ expression }} in a text node is a text binding — a path with an optional filter chain, the same as the right side of textContent:. The literal text stays, and each {{ }} becomes a text node of its own. It is on by default; 4.0 turns it off per state tree with $behavior: { enableMustache: false } (it was a bootstrapState() option), and the braces then stay as text.
テキストノードの中の{{ expression }}はテキストバインディングだ — 省略可能なフィルタの連鎖を伴うパスで、textContent:の右辺と同じだ。地の文はそのまま残り、{{ }}はそれぞれ自分のテキストノードになる。既定で有効で、4.0ではstateの木ごとに$behavior: { enableMustache: false }で切る(以前はbootstrapState()のオプションだった)。そのとき中括弧は文字のまま残る。
<p>Hello, {{ user.name }}!</p>
<p>Count: {{ count|locale }}</p>
<!-- the same binding as a comment: nothing shows before it is bound -->
<p>Hello, <!--@@: user.name-->!</p>
<p>Total: <!--@@wcs-text: total|locale--></p>
Comment bindings stay in 4.0. <!--@@: expression--> (or <!--@@wcs-text: expression-->) is the same text binding as {{ expression }}, filters included, written as a comment — nothing shows before the state loads, and no element is added — so the lint recommends it over a {{ }} outside a <template>. The comment is replaced by a text node at the same position, at page level and inside templates, and it is bound whatever enableMustache says. 4.0 lets the expression span several lines, leaves a comment inside <textarea> or <title> alone (a browser parses that content as text), no longer renders 3.x’s internal anchor comments (<!--@@wcs-for: x-->) as text, and drops the commentTextPrefix option that renamed the keyword.
コメントバインディングは4.0でも残る。 <!--@@: expression-->(または<!--@@wcs-text: expression-->)は、フィルタも含めて{{ expression }}と同じテキストバインディングをコメントとして書いたものだ — stateが読み込まれる前に何も見えず、要素も増えない — ので、lintは<template>の外の{{ }}よりこちらを勧める。コメントはページ直下でもテンプレートの中でも、同じ位置のテキストノードに置き換わり、enableMustacheが何であれバインドされる。4.0では式を複数行にまたがって書け、<textarea>や<title>の中のコメントには手を付けず(ブラウザはその中身を文字として解析する)、3.xの内部のアンカーのコメント(<!--@@wcs-for: x-->)を文字として描くことはもう無く、キーワードを改名するcommentTextPrefixオプションは無くなった。
...)Wire all properties and inputs of a wc-bindable element in one line. Expands to individual bindings per member; later bindings win; undefined skips the write (only null and defined values are written).
wc-bindable要素の全メンバーを1行で結線。後勝ちの上書き、undefinedは書き込みスキップ。
<wcs-fetch data-wcs="...: usersFetch"></wcs-fetch>
<!-- Inside a loop -->
<template data-wcs="for: storesFetches">
<wcs-fetch data-wcs="...: storesFetches.*"></wcs-fetch>
</template>
<!-- Last-wins override -->
<wcs-fetch data-wcs="...: usersFetch; status: alternateStatus"></wcs-fetch>
v3.4.0: when the element’s class is not defined yet, expansion waits for customElements.whenDefined(tag) inside for: / if: templates too. Since spread shipped in 1.11 a template expanded it at once, so an undefined element threw Spread binding … requires <x-greet> to be registered and the whole for: drew no row — the loop example above did that whenever @wcstack/fetch loaded after @wcstack/state. Rows and branches now render at once, and each row’s element is expanded with that row’s values when the class is defined; a row removed or a branch closed before then is skipped. An element taken out of the document while it waits and put back waits again on the registry of the tree it is back in.
v3.4.0: 要素のクラスがまだ定義されていないとき、展開はfor: / if:のテンプレートの中でもcustomElements.whenDefined(tag)を待つ。spreadが1.11で入って以来、テンプレートはそれをすぐに展開していたので、未定義の要素はSpread binding … requires <x-greet> to be registeredをthrowし、for:全体が1行も描かなかった — 上のループの例は、@wcstack/fetchが@wcstack/stateより後に読み込まれるたびにそうなった。いま行と枝はすぐに描かれ、クラスが定義されたときに各行の要素がその行の値で展開される。それまでに取り除かれた行や閉じた枝は飛ばされる。待っている間に文書から取り出されて戻された要素は、戻った先の木のレジストリで改めて待つ。
When a <wcs-state> binds its root — the document, a shadow root, or a Light DOM component’s host — it walks the markup once. A value is never markup: what a binding puts into the page is never read as bindings. 4.0 adds rules 3.x did not have:
textContent:, text:, innerText:, innerHTML:, html:) are not bound, at page level or inside templates — also when #init=element or #init=none leaves the children you wrote in place. Put markup that needs bindings in a separate element.<noscript> and <iframe> is not walked, as <script> and <style> were not; the element’s own data-wcs (srcdoc:, attr.src:) still binds.$errorCallback receives failures children first. Inside templates the order is document order.for: rows and if: branches are clones of their templates without the data-wcs attribute (3.x kept it, with the paths expanded), so a CSS rule or a test selector on [data-wcs…] no longer matches those elements — use a class or a data-* attribute of your own.for: / if: template, outerHTML: / outerText: fail initialization with [wcs/template-syntax] #203 — a row keeps its nodes by position, and 3.x applied the replacement once. Bind innerHTML: on a wrapper element instead.<wcs-state>は自分のルート — 文書、shadow root、あるいはLight DOMのコンポーネントのホスト — をバインドするとき、マークアップを一度だけ歩く。値は決してマークアップではない: バインディングがページに置いたものがバインディングとして読まれることは無い。4.0は3.xに無かった規則を加える。内容を設定するバインディング(textContent:・text:・innerText:・innerHTML:・html:)を持つ要素の子は、ページ直下でもテンプレートの中でもバインドされない — #init=elementや#init=noneが書いた子をそのまま残すときも同じだ。バインディングの要るマークアップは別の要素に置くこと。<noscript>と<iframe>の中身は、<script>や<style>と同じく歩かれない。要素自身のdata-wcs(srcdoc:・attr.src:)は今もバインドされる。ページ直下では要素の子が要素自身のバインディングより先にバインドされるので、カスタム要素が最初のプロパティの書き込みを受け取るのは子がバインドされた後で、$errorCallbackは子の失敗から先に受け取る。テンプレートの中では文書順だ。for:の行とif:の枝はテンプレートのdata-wcs属性を持たない複製なので(3.xはパスを展開して残していた)、[data-wcs…]に対するCSSのルールやテストのセレクタはもうその要素に当たらない — クラスや自分のdata-*属性を使うこと。for: / if:のテンプレートの中のouterHTML: / outerText:は[wcs/template-syntax] #203で初期化を失敗させる — 行はノードを位置で持ち、3.xは置き換えを一度だけ適用していた。代わりにラッパー要素にinnerHTML:をバインドすること。
for<template data-wcs="for: users">
<div data-wcs="textContent: users.*.name"></div> <!-- full path -->
<div data-wcs="textContent: .name"></div> <!-- dot shorthand -->
</template>
| Shorthand | Expands to |
|---|---|
.name | users.*.name |
. | users.* (current element) |
.name|upper | users.*.name|upper (filters preserved) |
<template data-wcs="for: regions">
<template data-wcs="for: .states">
<span data-wcs="textContent: .name"></span>
</template>
</template>
Lists use a value-based diff: elements are matched by value and DOM nodes are reused for unchanged items. No key attribute is needed, because non-destructive array methods (toSorted, toReversed, filter, with) preserve element references — sorting and filtering are keyed by construction.
リストは値ベースの差分。変化のない要素のDOMノードは再利用される。toSorted / toReversed / filter / with は要素の参照を保つため、key属性は不要。
for: takes no output filters (4.0). for: items|take(2) fails initialization with [wcs/binding-syntax] #121, whatever the filter; 3.x rendered the rows of the filtered array. A row of for: items is items.<index>, so the rows of a filtered array would name other elements, and writes through them would land on the wrong ones. Declare a getter that returns the filtered list, and loop over it. The getter returns a copy, and a write reaches both sides: through the original path (this["items.1.name"] = "z") it reaches the copy’s row, and through the row (firstTwo.1.name, or a binding inside the row) the original list’s row. The getter is evaluated again because its array holds the written object — with a filter (get shown() returning the undone items of todos, rendered with for: shown), a row whose checkbox writes .done leaves the view (#365). A getter that returns new objects (map) is not evaluated again by such a write.
<!-- 3.x -->
<template data-wcs="for: items|take(2)">…</template>
<!-- 4.0, with get firstTwo() { return this.items.slice(0, 2); } -->
<template data-wcs="for: firstTwo">
<li data-wcs="textContent: .name"></li>
</template>
for:は出力フィルタを取らない(4.0)。 for: items|take(2)は、フィルタが何であれ[wcs/binding-syntax] #121で初期化を失敗させる。3.xはフィルタを通した配列の行を描いていた。for: itemsの行はitems.<index>なので、フィルタを通した配列の行は別の要素を名指してしまい、その行を通した書き込みは違う要素に落ちる。絞り込んだリストを返すgetterを宣言し、その上で回すこと。getterはコピーを返し、書き込みは両側に届く: 元のパス(this["items.1.name"] = "z")からはコピーの行に、行(firstTwo.1.nameや行の中のバインディング)からは元のリストの行に。getterは、その配列が書かれたオブジェクトを持つので再び評価される — 絞り込み(todosの未完了の要素を返すget shown()をfor: shownで描く)なら、チェックボックスが.doneに書いた行はビューから外れる(#365)。新しいオブジェクトを返すgetter(map)は、そうした書き込みでは再評価されない。
v2.5.0: swapping or replacing rows by writing elements — this.$resolve("items.*", [0], b), this["items.0"] = c — keeps the page, the reads and the writes on the same rows. A completed swap is re-rendered as a replacement of the order before the writes, so the row blocks move with their values, and a row’s $1 and the DOM state the bindings do not own (text typed into an unbound input) move with them. Writing a value that was not in the list replaces that row in place: the block stays where it is, an input being edited in it keeps focus, and the row’s getters show the new row. Equal primitives cannot be told apart, so writes that end in a reordering of the same values count as a swap. The completed swap is queued render-only, so $watch fires for neither items nor items.* on a row that merely moved. On ≤2.4 the written value was pinned to the position’s old row and the list was never re-rendered — page, path reads, writes and $1 all disagreed with the array, silently.
v2.5.0: 要素への書き込みで行を入れ替える・置き換える — this.$resolve("items.*", [0], b)、this["items.0"] = c — とき、ページと読みと書き込みは同じ行に揃ったままになる。完了したswapは書き込み前の順序に対する置き換えとして再描画されるので、行のブロックは値とともに動き、行の$1やバインディングが所有していないDOM状態(バインドしていない入力欄に打ち込んだ文字)も一緒に動く。リストに無かった値を書けばその行はその場で置き換わる: ブロックは動かず、編集中の入力欄はフォーカスを保ち、その行のgetterは新しい行を映す。等しいプリミティブは区別できないので、同じ値の並べ替えに終わる書き込みはswapとして扱われる。完了したswapは描画のみでキューされるため、ただ移動しただけの行について$watchはitemsにもitems.*にも発火しない。≤2.4では書かれた値がその位置の古い行に貼り付けられ、リストは再描画されなかった — ページもパスの読みも書き込みも$1も、黙って配列と食い違っていた。
v4.0.0: writing an element replaces the value at that position instead. The row stays where it is, with its $1, and takes the new value; DOM state the bindings do not own — focus, text typed into an unbound input, an open <details> — stays with the position, so an input bound to the row (value: .) keeps focus while you type. To move rows together with their values, assign a new array: rows are matched to the elements of the new array by identity.
// 3.x: the two rows traded places once both writes had landed
// 4.0: the values trade places; the rows stay where they are
const a = this.$resolve("items.*", [0]);
const b = this.$resolve("items.*", [1]);
this.$resolve("items.*", [0], b);
this.$resolve("items.*", [1], a);
// 4.0: to move the rows, with their DOM, assign a new array
const items = this.items.slice();
[items[0], items[1]] = [items[1], items[0]];
this.items = items;
Reconciling a list whose items repeat — a board of 0 / 1 cells, tags, numbers — no longer goes quadratic in 4.0: a 90,000-cell board goes from 4.6 s to 6 ms. A row whose value is where it was keeps its place, so changing two cells of a board rebuilds two rows; which row (and DOM) a whole-array assignment reuses among equal values changes with it.
v4.0.0: 要素への書き込みは、代わりにその位置の値を置き換える。行は$1ごとその場にとどまって新しい値を受け取り、バインディングが持たないDOMの状態 — フォーカス、バインドしていない入力欄に打ち込んだ文字、開いた<details> — は位置とともに残る。だから行にバインドした入力欄(value: .)は、打ち込んでいる間フォーカスを保つ。行を値ごと動かすには新しい配列を代入する: 行は同一性で新しい配列の要素に照合される。要素が繰り返すリストの照合ももう二乗にならない: 9万セルの盤面で4.6 sから6 msへ。値が元の位置にある行はその場にとどまるので、2つのセルを変えれば作り直すのは2行だ。等しい値のうちどの行(とDOM)を配列全体の代入が使い回すかは、これに合わせて変わる。
v3.4.0: rows holding an element whose wc-bindable member takes its initial value from the element — an output-only member such as <wcs-fetch>’s value / loading, a member marked #init=element, or #init=auto on a row with no value yet — render again. When every row of a render was new (the first render, filling an empty list, a refetch that replaces the rows) and the tag was already defined — the recommended load order — the initial sync threw No state tree found on this root for initial binding sync. and the list stayed empty with a console.error; this goes back to 1.21. A value such an element writes to its own row (status: ., or an input bound with value: .) updates that row and is never taken for half of a swap, so equal reports no longer shuffle the rows or leave stale ones behind. A for: that renders the same list as another for: catches up when it comes back after an if: hid it — two views of one list no longer need both kept mounted behind hidden — and a for: over a getter that returns the list itself (get shown() { return this.filter === "all" ? this.todos : … }) redraws a row as soon as it is written through todos. {{ $1 }} inside a nested template — an inner for:’s rows, an if: branch — shows the outer row’s new position after a reorder. Writing an element right after replacing the list in the same batch, restoring an array the list held before an element write, and a second for: or if: in the same parent after a list dropped every row while detached each lost or misdrew rows before.
v3.4.0: wc-bindableのメンバーが初期値を要素から受け取る要素 — <wcs-fetch>のvalue / loadingのような出力専用のメンバー、#init=elementを付けたメンバー、まだ値の無い行での#init=auto — を持つ行が再び描かれる。描画のすべての行が新しく(最初の描画、空のリストを埋めるとき、行を置き換える再取得)、タグがすでに定義されていると — 推奨のロード順だ — 初期同期がNo state tree found on this root for initial binding sync.をthrowし、リストはconsole.errorを残して空のままだった。これは1.21までさかのぼる。そうした要素が自分の行に書く値(status: .や、value: .でバインドした入力欄)はその行を更新し、swapの片割れと取り違えられることは無いので、同じ値の報告が行を入れ替えたり古い行を残したりすることはもう無い。別のfor:と同じリストを描くfor:は、if:に隠された後に戻ってくると追いつく — 1つのリストの2つのビューを、両方マウントしたままhiddenで切り替える必要はもう無い — そしてリストそのものを返すgetterの上のfor:(get shown() { return this.filter === "all" ? this.todos : … })は、todosを通して行が書かれたその場でその行を描き直す。入れ子のテンプレート — 内側のfor:の行、if:の枝 — の中の{{ $1 }}は、並べ替えの後に外側の行の新しい位置を示す。同じバッチでリストを置き換えた直後の要素の書き込み、要素の書き込みの前にリストが持っていた配列の復元、切り離されている間にリストが全行を落とした後の同じ親の中の2つ目のfor:やif:は、どれも以前は行を失うか描き違えていた。
Inner arrays shared across outer rows (v3.4.0). When two outer rows hold the same inner array (groups: [{ items: A }, { items: A }], with for: .items inside for: groups), replacing an element of it redraws it in every for: that draws it, as before, and so now do a later write below the replaced position, an element swap inside the shared list, and an outer row that an if: hid while the element was replaced — 3.3.0 left the other outer row showing old values in each. A copy that one outer row takes (groups.1.items = [...groups.1.items]) gets rows of its own, so a write through that outer row lands in the copy and one through the other lands in the original — in 3.3.0 either could land in the other array. And when the outer row that first expanded an array stops holding it, another outer row that still holds it takes its rows over. Three shapes are known limits: the same array held under different keys by different outer rows can take a write on the wrong element (#396); one array shared between two <wcs-state> elements, a root and its volume included, can send a write to the wrong array, or throw and lose it, after a copy or a rebuild in one of them (#397) — 3.3.0 got both of those right only because the indexes happened to line up; and stacking copies, moves and rebuilds of shared arrays in one update can leave some for: blocks showing old values while the state is right (#398). Give each row an array of its own where you can. 4.0 resolves all three: writes land on the element written and the lists that render it follow, except where one object is reached through two arrays that no getter relates — two outer rows holding different arrays that share an object (#401), where 3.4 and 3.5 still redrew the other outer row’s leaf bindings and 4.0 leaves them stale until a $postUpdate("groups").
外側の行どうしで共有する内側の配列(v3.4.0)。 2つの外側の行が同じ内側の配列を持つとき(groups: [{ items: A }, { items: A }]を、for: groupsの中のfor: .itemsで描く)、その要素の置き換えは以前どおり、それを描くすべてのfor:で描き直され、いまは置き換えた位置の下への後からの書き込み、共有したリストの中の要素のswap、要素の置き換えの間if:に隠されていた外側の行も同じように描き直される — 3.3.0はどれも、もう一方の外側の行に古い値を残していた。外側の行の1つが取ったコピー(groups.1.items = [...groups.1.items])は自分の行を持つので、その外側の行を通した書き込みはコピーに、もう一方を通した書き込みは元の配列に届く — 3.3.0ではどちらも、もう一方の配列に届くことがあった。そして配列を最初に展開した外側の行がそれを持たなくなると、まだ持っている別の外側の行がその行を引き継ぐ。3つの形は既知の制限だ: 異なる外側の行が同じ配列を異なるキーで持つと、書き込みが違う要素に落ちることがある(#396)。2つの<wcs-state>要素 — ルートとそのボリュームを含む — で1つの配列を共有すると、片方でのコピーや作り直しの後、もう片方を通した書き込みが違う配列に届くか、throwして失われることがある(#397) — この2つを3.3.0が正しく扱えていたのは、たまたまインデックスが揃っていたからにすぎない。そして共有した配列のコピー・移動・作り直しを1つの更新に積み重ねると、stateは正しいまま、いくつかのfor:のブロックが古い値を示し続けることがある(#398)。できるかぎり、行ごとに自分の配列を持たせること。4.0は3つとも解決する: 書き込みは書いた要素に届き、それを描くリストが追従する。例外は、getterが関係づけていない2つの配列を通して1つのオブジェクトに届く場合だ — オブジェクトを共有する別々の配列を持つ2つの外側の行(#401)では、3.4と3.5はもう一方の外側の行の葉のバインディングを描き直していたが、4.0は$postUpdate("groups")までそれを古いまま残す。
$listKeys — identity for refetched rowsThe exception, addressed in v1.26.0, is data that arrives as freshly created objects — fetch(...).json(), a WebSocket snapshot, JSON.parse from storage. Nothing matches by reference, so every row is rebuilt and the DOM state the bindings do not own goes with it: focus, an in-flight IME composition, <details> open state, inner scroll, <canvas> contents. Declare a key and rows are matched across the refresh instead.
例外は毎回新しいオブジェクトとして届くデータ(fetch(...).json()、WebSocketのスナップショット、ストレージからのJSON.parse)。参照が一致しないため全行が作り直され、バインディングが所有していないDOM状態 — フォーカス、変換中のIME入力、<details>の開閉、内側のスクロール、<canvas>の内容 — が失われる。キーを宣言すれば、リフレッシュを越えて行が突き合わせられる。
export default {
items: [],
$listKeys: {
"items": "id", // field name
"items.*.children": (row) => row.uid, // or a function, for composite keys
},
async load() {
// Every row object is new, but rows are matched by id: the DOM is reused
// and only the fields that actually differ are written.
this.items = await (await fetch("/api/items")).json();
},
};
null. The stored array is rebuilt from the matched row objects, so this.items !== theArrayYouAssigned afterwards.@odata.etag, 2fa, a.b), or that has the name of a row getter, is copied into the kept row as it is, and the row is updated as a whole; a __proto__ key from JSON is not copied.$listKeys is the list-keys feature — the full entries include it, and a /core page installs @wcstack/state/features/list-keys. A mounted component’s $listKeys keys its own lists; a volume may not declare one.4.0: 名前が1つのパスの区間でないフィールド(@odata.etag・2fa・a.b)や行getterと同じ名前のフィールドは、保たれた行にそのままコピーされ、行は丸ごと更新される。JSONから来た__proto__のキーはコピーされない。$listKeysはlist-keys機能になった — 完全版のエントリはそれを載せ、/coreのページは@wcstack/state/features/list-keysを組み込む。マウントされたコンポーネントの$listKeysは自分のリストを鍵付けし、ボリュームはそれを宣言できない。
if / elseif / else<template data-wcs="if: count|gt(0)">
<p>Positive</p>
</template>
<template data-wcs="elseif: count|lt(0)">
<p>Negative</p>
</template>
<template data-wcs="else:">
<p>Zero</p>
</template>
A structural binding — for, if, elseif, else — must be the only binding in its data-wcs; sharing the attribute with anything else raises [wcs/template-syntax]. Put other bindings on elements inside the template. The lint CLI checks the same shape as of v1.29.0.
構造バインディング(for・if・elseif・else)は、そのdata-wcsの中で唯一のバインディングでなければならない。他のバインディングと共有すると[wcs/template-syntax]が投げられる。他のバインディングはテンプレート内の要素に書く。lint CLIもv1.29.0から同じ形を検査する。
Structural templates in inserted content (4.0). Content another package inserts after the page was bound — a route body that @wcstack/router shows, a <wcs-head> child — is bound when the inserter hands it over through the binder protocol. A for: / if: template at the top of such content renders beside itself, so 4.0 renders it only when the inserter declares that it carries the whole range in and out — bind(subtree, { range: true }), as @wcstack/router 3.5 and later does for route content and a layout’s content. So a for: directly under <wcs-route> now renders on navigation too, and its rows leave and come back with the route; 3.x never rendered it there. Without that declaration it is not rendered, and the console reports [wcs/template-syntax] #204. A template wrapped in an element renders in every case, and a {{ }} written directly in a route body, outside any element, is still not bound on navigation.
差し込まれた中身の構造テンプレート(4.0)。 ページのバインド後に別のパッケージが差し込む中身 — @wcstack/routerが見せるルートの本体、<wcs-head>の子 — は、差し込む側がバインダーのプロトコルで渡したときにバインドされる。そうした中身の最上位にあるfor: / if:のテンプレートは自分の脇に描くので、4.0がそれを描くのは、差し込む側が範囲全体を出し入れすると宣言したとき — bind(subtree, { range: true })。@wcstack/router 3.5以降はルートの中身とレイアウトの中身でそうする — だけだ。だから<wcs-route>の直下のfor:はいまナビゲーションでも描かれ、その行はルートと一緒に出ていって戻る。3.xはそこでは決して描かなかった。宣言が無ければ描かれず、コンソールに[wcs/template-syntax] #204が報告される。要素で包んだテンプレートはどの場合も描かれ、ルートの本体に要素の外で直接書いた{{ }}は今もナビゲーションではバインドされない。
Chain with |: price|mul(1.1)|round(2)|locale(ja-JP). In 4.0 the 10 condition filters (eq ne not lt le gt ge truthy falsy boolean) are in the core, and the other 38 in the formats feature, which the full entries include (see Split entries).
パイプで連結できる。4.0では10個の条件フィルタ(eq ne not lt le gt ge truthy falsy boolean)がコアにあり、残りの38個は完全版のエントリが載せているformats機能にある(分割エントリを参照)。
eq(v)ne(v)notlt(n)le(n)gt(n)ge(n)add(n)sub(n)mul(n)div(n)mod(n)absclamp(min,max)toFixed(n)round(n?)floorceillocale(loc?)percent(n?)unit(u)currency(code,loc?)upperlowercapitalizetrimslice(s,e?)padStart(n,c?)padEnd(n,c?)repeat(n)reversetruncate(n,sfx?)join(sep?)intfloatbooleannumberstringnullIfEmptydate(loc?,style?)time(loc?,style?)datetime(loc?,style?)ymd(sep?)hms(sep?)truthyfalsydefaults(v)coalesce(v)Canonical names (v3.2.0). Some names read as the wrong operation or abbreviated away from JavaScript’s, and the chips above show their canonical names: add / sub (were inc / dec), toFixed (fix), upper / lower / capitalize (uc / lc / cap), repeat / reverse (rep / rev), padStart (pad) and nullIfEmpty (null — it turns an empty string into null and passes anything else through). The old names resolve to the same implementation and arity through 3.x and are removed in 4.0; lint and the VS Code extension flag each one as wcs/name-alias (info) with the canonical name, and the runtime starts warning only in the last 3.x minor. Two filters are new: padEnd(n, c?) pads the end (default a space), and coalesce(v) replaces only null / undefined — count|coalesce(0) keeps a real 0, false or "", where defaults(v) replaces every falsy value. Fixed on the way: add / sub require their argument, as documented — the arity table said it was optional, so lint let inc pass without one and the runtime failed without a code; it now fails as [wcs/filter-arity]. On a page pinned to 3.1 or earlier, write the old names.
正規の名前(v3.2.0)。 違う操作に読める名前や、JavaScriptの名前から省略されすぎた名前があり、上のチップはその正規の名前を示している: add / sub(旧inc / dec)、toFixed(fix)、upper / lower / capitalize(uc / lc / cap)、repeat / reverse(rep / rev)、padStart(pad)、nullIfEmpty(null — 空文字列をnullにし、それ以外は素通しする)。旧名は3.xの間は同じ実装・同じアリティに解決され、4.0で取り除かれる。lintとVS Code拡張は旧名ごとに正規の名前を添えてwcs/name-alias(info)で指摘し、ランタイムが警告を出すのは3.xの最後のマイナーからだ。新しいフィルタが2つある: padEnd(n, c?)は末尾を埋め(既定は空白)、coalesce(v)はnull / undefinedだけを置き換える — count|coalesce(0)は本物の0・false・""を保ち、defaults(v)はあらゆる偽の値を置き換える。ついでに直ったもの: add / subは文書どおり引数を必須とする — アリティ表が省略可能としていたため、lintは引数の無いincを通し、ランタイムはコード無しで失敗していた。いまは[wcs/filter-arity]で失敗する。3.1以前に固定したページでは旧名を書くこと。
Argument literals are typed (3.0). An unquoted true, false, null or number is that value; a quoted argument is a string. done|eq(true) matches true (in 2.x it compared with the string "true" and never matched), eq('true') does not, and eq(null) matches null. Numbers and strings compare as before — a numeric value against the number, a string value against the text — so a form value "1" still matches eq(1). defaults(v) returns the typed value: defaults(0) gives 0, defaults('0') gives "0". truthy / falsy / defaults use JavaScript’s own truthiness, like boolean, so 0n is falsy.
引数のリテラルには型がある(3.0)。 引用符の無いtrue・false・null・数値はその値そのもので、引用符付きの引数は文字列だ。done|eq(true)はtrueに一致し(2.xでは文字列"true"と比べて決して一致しなかった)、eq('true')は一致せず、eq(null)はnullに一致する。数値と文字列の比較は以前どおり — 数値の値は数値と、文字列の値は文字列と — なので、フォームの値"1"は今もeq(1)に一致する。defaults(v)は型付きの値を返す: defaults(0)は0、defaults('0')は"0"。truthy / falsy / defaultsはbooleanと同じくJavaScript自身の真偽判定を使うので、0nは偽だ。
v3.3.0: date / time / datetime accept their locale argument again (createdAt|date(ja-JP) failed [wcs/filter-arity] from 3.0 to 3.2), substr requires both of its arguments when the bindings are planned, and selectedId|eq(null) no longer throws once the id is a number. if: 0 renders the else: branch again — not is the lenient !value with or without features/formats. Object.prototype names (|toString) report [wcs/filter-unknown], and filters are cached by their argument text as well as their typed values, so truncate(3,1.0) and truncate(3,1) stay apart.
v3.3.0: date / time / datetimeはロケール引数を再び受け付ける(createdAt|date(ja-JP)は3.0から3.2まで[wcs/filter-arity]で失敗していた)。substrはバインディングの計画時に2つの引数をどちらも要求し、selectedId|eq(null)はidが数値になってもthrowしなくなった。if: 0は再びelse:の枝を描く — notはfeatures/formatsの有無にかかわらず寛容な!valueだ。Object.prototypeの名前(|toString)は[wcs/filter-unknown]を報告し、フィルタは型付きの値に加えて引数の文字列でもキャッシュされるので、truncate(3,1.0)とtruncate(3,1)は取り違えられない。
v3.5.0: the runtime warning promised for the last 3.x minor is here. When a binding that uses an old name is set up, the full entries print it once per page with the canonical one ([wcs/v4-migration] filter "uc" is removed in 4.0: write "upper" (its name since 3.2).), and substr joins them: 4.0 removes it, so write slice(start, start + length) — slice takes an end index, not a length. For a start of 0 or more the two agree; a negative start counts from the end in both, but the end differs, so check those calls by hand. Lint and the VS Code extension report substr as wcs/v4-migration (info) and give the exact call when the arguments are non-negative literals (substr(2, 3) → slice(2, 5)). Every old name still resolves to the same implementation through 3.x.
v3.5.0: 3.xの最後のマイナーに約束されていたランタイムの警告がここに来た。旧名を使うバインディングが組み立てられると、完全版のエントリは正規の名前を添えてページごとに一度それを出し([wcs/v4-migration] filter "uc" is removed in 4.0: write "upper" (its name since 3.2).)、substrもそこに加わる: 4.0はこれを取り除くので、slice(start, start + length)と書くこと — sliceが取るのは長さではなく終わりのインデックスだ。startが0以上なら両者は一致する。負のstartはどちらも末尾から数えるが終わりが異なるので、そうした呼び出しは手で確かめること。lintとVS Code拡張はsubstrをwcs/v4-migration(info)として報告し、引数が負でないリテラルなら正確な呼び出しを示す(substr(2, 3) → slice(2, 5))。旧名はどれも、3.xの間は同じ実装に解決され続ける。
v4.0.0: the ten old names and substr do not exist. They fail like any unknown name, with [wcs/filter-unknown] (#501) when the page is initialized, and the message names what to write instead rather than the nearest built-in name, which for these is an unrelated filter (following dec → eq would change the meaning silently): filter not found: dec. "dec" was renamed "sub" in 3.2 and removed in 4.0 — write "sub". The replacement comes from the diagnostics feature; on /core without it the message is #501 "dec". The lint reports them as wcs/filter-unknown, naming the replacement (and the exact slice call for substr), at error since 4.0.1, as the runtime throws — 4.0.0’s lint said warning, on the grounds that a page could register filters of its own, which no public API does.
v4.0.0: 10個の旧名とsubstrは存在しない。どれも未知の名前と同じく、ページの初期化時に[wcs/filter-unknown](#501)で失敗し、メッセージは最も近い組み込みの名前ではなく代わりに書くものを名指しする。これらに最も近い名前は無関係なフィルタだからだ(dec → eqに従えば意味が黙って変わる): filter not found: dec. "dec" was renamed "sub" in 3.2 and removed in 4.0 — write "sub". 置き換え先はdiagnostics機能が出すもので、それの無い/coreではメッセージは#501 "dec"になる。lintはこれらをwcs/filter-unknownとして、置き換え先(substrなら正確なsliceの呼び出し)を添えて報告する。4.0.1からは、ランタイムがthrowするとおりerrorだ — 4.0.0のlintは、ページが自分のフィルタを登録できるという理由でwarningにしていたが、それをする公開APIは無い。
A filter is resolved when the bindings are planned, not when they are parsed. An unknown name throws [wcs/filter-unknown] with a did-you-mean, and as of 3.0 an argument count outside what the filter accepts throws [wcs/filter-arity] (join(a,b): “accepts at most 1 argument(s) (2 given)”) — the same code and bounds lint reports. In 4.0 either stops the <wcs-state>’s initialization (see Lifecycle hooks). Arguments are cached by their structure, so slice('1,2') and slice(1,2) are never confused.
フィルタが解決されるのは、バインディングを解析したときではなく計画したときだ。未知の名前はdid-you-meanつきで[wcs/filter-unknown]をthrowし、3.0からはフィルタが受け付ける範囲の外の引数の個数が[wcs/filter-arity]をthrowする(join(a,b): 「accepts at most 1 argument(s) (2 given)」) — lintが報告するのと同じコード、同じ範囲だ。4.0ではどちらも<wcs-state>の初期化を止める(ライフサイクルフックを参照)。引数は構造でキャッシュされるので、slice('1,2')とslice(1,2)が取り違えられることはない。
The default locale is <html lang> as of v1.32.0 (breaking; it was 'en'). The four locale-dependent filters — locale, date, time, datetime — read config.locale, which now defaults to the page’s declared language, falling back to 'en'. An explicit bootstrapState({ locale }) still wins, and per-call overrides (price|locale(fr-FR)) are fixed at bind time. Changing config.locale after render updates nothing — set <html lang> in the markup, before the page renders. 4.0: <html lang> is read when the module is evaluated (3.x read it when bootstrapState() ran), and a tag Intl does not take (en_US) is reported once (#48) while the filters use "en". As in 3.x, the filters read the locale on every apply, so a binding that re-renders for its own reasons picks up a later value.
既定ロケールはv1.32.0から<html lang>(破壊的変更。従来は'en')。ロケール依存の4フィルタ — locale・date・time・datetime — が読むconfig.localeの既定がページの宣言言語になり、なければ'en'に落ちる。明示的なbootstrapState({ locale })が常に勝ち、per-callの上書き(price|locale(fr-FR))はバインド時に固定される。描画後にconfig.localeを変えても何も更新されない — <html lang>はマークアップに、ページが描画される前に書く。4.0: <html lang>はモジュールの評価時に読まれ(3.xはbootstrapState()の実行時に読んでいた)、Intlが受け付けないタグ(en_US)は一度だけ報告され(#48)、フィルタは"en"を使う。3.xと同じく、フィルタは適用のたびにロケールを読むので、自分の理由で描き直されるバインディングは後からの値を拾う。
Six arrived in v1.27.0 — abs, clamp(min,max), unit(u), join(sep?), truncate(n,suffix?) and hms(sep?) — picked so presentation stays on the wire instead of leaking into state. The chain they were designed around: style.height: cpu|clamp(0,100)|toFixed(0)|unit(%). unit deliberately accepts the strings that toFixed/percent return, and passes null/undefined through untouched so “undefined skips the write, null clears” survives the filter. Also fixed in v1.27.0: argument trimming is outside-quotes only, so padStart(5, ' ') and join(' / ') finally mean what they say.
v1.27.0で6つ追加 — abs・clamp(min,max)・unit(u)・join(sep?)・truncate(n,suffix?)・hms(sep?)。表示の都合をstateに持ち込まず、バインディングの上で済ませるための選定で、設計の起点はstyle.height: cpu|clamp(0,100)|toFixed(0)|unit(%)というチェーン。unitはtoFixed/percentが返す文字列を意図的に受け入れ、null/undefinedは素通しするので「undefinedは書き込みスキップ、nullはクリア」の意味論がフィルタを通っても保たれる。同じくv1.27.0で、引数のトリムはクォートの外側だけになり、padStart(5, ' ')やjoin(' / ')が書いたとおりに動くようになった。
v4.2.0: currency(code,loc?) formats money in an ISO 4217 currency (price|currency(JPY), price|currency(USD,en-US) → $1,234.50), and date / time / datetime take a style after the locale (createdAt|date(en-US,medium) → Oct 13, 2026).
v4.2.0: currency(code,loc?)はISO 4217の通貨で金額を書く(price|currency(JPY)、price|currency(USD,en-US) → $1,234.50)。date / time / datetimeはロケールの後にスタイルを取る(createdAt|date(en-US,medium) → Oct 13, 2026)。
Handler signature: handler(event, ...listIndexes) — loop indexes arrive as extra arguments.
ハンドラにはイベントに続いてループのインデックスが渡される。
v3.3.0: a DOM event handler that throws synchronously — an undeclared command token, a missing handler name, an exception in your method — is reported with console.error instead of sinking into an unhandled rejection. Handlers reached synchronously (two-way, radio, checkbox) still propagate.
v3.3.0: 同期的にthrowするDOMイベントハンドラ — 宣言していないコマンドトークン、見つからないハンドラ名、メソッド内の例外 — は、未処理のrejectionに沈む代わりにconsole.errorで報告される。同期的に呼ばれるハンドラ(双方向・radio・checkbox)は今も伝播する。
v3.5.0: on*#direct: can be written today. 4.0 delegates on*: for the common bubbling events — click, dblclick, input, change, submit, keydown, keyup, mousedown, mouseup, pointerdown, pointerup — to the root, so there a handler’s event.currentTarget is the root, and adds #direct to keep the listener on the element. 3.x attaches every on*: to its element and accepts #direct silently, so onclick#direct: behaves the same on both. Lint and the VS Code extension flag a handler of such an event that reads currentTarget from its event parameter before its first await (wcs/v4-migration, info): write #direct, keeping any modifiers (onclick#prevent,direct:), or find the element with event.target.closest(...). Write it now where the element-attached behaviour matters: with onclick#direct,stop: the binding stops your own listeners on the ancestors, and the handler runs even when an ancestor calls stopPropagation() — as every on*: does in 3.x.
v3.5.0: on*#direct:はいまから書ける。4.0はよく使うバブリングするイベント — click・dblclick・input・change・submit・keydown・keyup・mousedown・mouseup・pointerdown・pointerup — のon*:をルートに委譲するので、そこではハンドラのevent.currentTargetはルートになる。そしてリスナーを要素に保つための#directを加える。3.xはすべてのon*:を要素に付け、#directを黙って受け付けるので、onclick#direct:はどちらでも同じに動く。lintとVS Code拡張は、そうしたイベントのハンドラが最初のawaitより前にイベント引数からcurrentTargetを読む箇所を指摘する(wcs/v4-migration、info): 書いてある修飾子は残したまま#directを足すか(onclick#prevent,direct:)、event.target.closest(...)で要素を探すこと。要素に付いた挙動が要る場面では、いま書いておくこと: onclick#direct,stop:なら祖先にある自分のリスナーを止め、祖先がstopPropagation()を呼んでもハンドラは走る — 3.xのすべてのon*:と同じように。
v4.0.0: delegated events. on*: bindings of click, dblclick, input, change, submit, keydown, keyup, mousedown, mouseup, pointerdown and pointerup are delegated: one listener per event type sits on the root the state binds — the document, a shadow root, or, inside a Light DOM mounted component, the host element — and runs the handlers of the elements the event passed, innermost first. Other event types, an event a custom element dispatches with bubbles: false, two-way bindings (value:, checked:, radio:, checkbox:) and eventToken. bindings stay on the element. on*#direct: attaches one binding’s listener to the element itself, as 3.x did, per row inside for: / if:. An outer #direct handler runs before the delegated handlers inside it, because those run at the root, so an inner #stop cannot stop it unless the inner binding is #direct too. this in a handler is the writable state, and a dotted name is a path: onclick: cart.checkout calls the method of the volume mounted at cart.
3.x, and #direct in 4.0 | 4.0, delegated | |
|---|---|---|
event.currentTarget in the handler | the element | the root — use event.target.closest(…) or the loop index the handler receives |
#stop on an inner on*: binding | stops the outer on*: handlers | the same |
#stop inside an element your own code listens on (addEventListener on an ancestor) | stops that listener | does not stop it: it ran before the event reached the root |
Your code calls stopPropagation() on an ancestor (a modal’s content) | the inner handler still runs | the inner handler never runs |
The element is moved under another root (a dialog moved to document.body) | its handler runs | its handler does not run |
v4.0.0: 委譲されるイベント。 click・dblclick・input・change・submit・keydown・keyup・mousedown・mouseup・pointerdown・pointerupのon*:バインディングは委譲される: 種類ごとに1つのリスナーが、stateがバインドするルート — 文書、shadow root、あるいはLight DOMのマウントされたコンポーネントの中ではホスト要素 — に置かれ、イベントが通った要素のハンドラを内側から順に走らせる。それ以外の種類のイベント、カスタム要素がbubbles: falseで送るイベント、双方向バインディング(value:・checked:・radio:・checkbox:)、eventToken.バインディングは要素に残る。on*#direct:は、3.xと同じく、バインディング1つのリスナーを要素そのものに付け、for: / if:の中では行ごとに付ける。外側の#directハンドラは、その内側の委譲されたハンドラより先に走る。委譲されたものはルートで走るからだ。だから内側の#stopは、内側のバインディングも#directでない限りそれを止められない。ハンドラのthisは書き込み可能なstateで、ドット付きの名前はパスだ: onclick: cart.checkoutはcartにマウントしたボリュームのメソッドを呼ぶ。表のとおり、委譲されたハンドラではevent.currentTargetはルートになり(event.target.closest(…)かハンドラが受け取るループのインデックスを使う)、自分のコードが祖先に付けたリスナーは#stopで止まらず、祖先がstopPropagation()を呼べば内側のハンドラは走らず、別のルートへ動かした要素のハンドラは走らない。
<button data-wcs="onclick: handleClick">Click</button>
<form data-wcs="onsubmit#prevent: handleSubmit">...</form>
<!-- 4.0: a button inside a card whose click listener your own code added -->
<button data-wcs="onclick#direct,stop: save">Save</button>
handleClick(event) { console.log("clicked"); }
removeItem(event, index) {
this.items = this.items.toSpliced(index, 1);
}
Pub/sub channels for invoking element methods from state. Declare channels in $commandTokens, emit arguments with this.$command.<name>.emit().
状態から要素のメソッドを呼ぶpub/subチャンネル。
export default {
$commandTokens: ["fetchUsers", "refreshOrders"],
onClickFetch() {
this.$command.fetchUsers.emit("/api/users", { method: "GET" });
}
};
<!-- Subscribe a wc-bindable command to the token -->
<wcs-fetch data-wcs="command.fetch: $command.fetchUsers"></wcs-fetch>
<!-- Or emit the token directly from an event -->
<button data-wcs="onclick: $command.refreshList">Refresh</button>
| Token API | Purpose |
|---|---|
emit(...args) | Invoke subscribed methods; returns array of results. v3.3.0: a subscriber that throws is reported with console.error and the others still receive the call — emit no longer rethrows to the emitter |
subscribe(fn) | Add subscriber; returns unsubscribe function |
unsubscribe(fn) | Remove subscriber |
name / size | Token name / current subscriber count |
With the native-commands feature (@wcstack/state and /auto include it; a /core page installs @wcstack/state/features/native-commands), command.<method>: also subscribes a native element’s method. Opening a dialog, focusing an input or playing a video becomes a token the state emits — the state code never reaches for the element. 3.x refused a native element (command binding requires a wc-bindable custom element), and /core without the feature still does (#1202).
native-commands機能があれば(@wcstack/stateと/autoは載せている。/coreのページは@wcstack/state/features/native-commandsを組み込む)、command.<method>:はネイティブ要素のメソッドも購読する。ダイアログを開く、入力欄にフォーカスする、動画を再生するといったことが、stateがemitするトークンになる — stateのコードが要素に手を伸ばすことは無い。3.xはネイティブ要素を拒み(command binding requires a wc-bindable custom element)、機能の無い/coreは今も拒む(#1202)。
<button data-wcs="onclick: $command.openEditor">Edit</button>
<dialog data-wcs="command.showModal: $command.openEditor; command.close: $command.closeEditor">
<input data-wcs="value: title">
<button data-wcs="onclick: save">Save</button>
</dialog>
export default {
title: "",
$commandTokens: ["openEditor", "closeEditor"],
async save() {
await saveTitle(this.title);
this.$command.closeEditor.emit("saved"); // → dialog.close("saved")
},
};
| Element | Methods |
|---|---|
| any element | focus blur click scrollIntoView showPopover hidePopover togglePopover |
<dialog> | show showModal close requestClose |
<form> | requestSubmit checkValidity reportValidity |
<input> | select setSelectionRange showPicker setCustomValidity checkValidity reportValidity |
<textarea> | select setSelectionRange setCustomValidity checkValidity reportValidity |
<select> | showPicker setCustomValidity checkValidity reportValidity |
<audio> <video> | play pause load |
A method outside the table throws [wcs/token-misconfigured] (#1205, naming the methods that element may run) when the binding is attached. Left out on purpose: the methods that write HTML, an attribute or the tree (bind html: / attr. instead), those that change a control’s value without an input event (form.reset(), stepUp() — write the state instead), form.submit(), which skips validation, and the fullscreen / pointer-lock / picture-in-picture requests, which their I/O nodes cover. A native method receives the emit arguments unless the first one is an Event: an on…: $command.x binding emits (event, ...listIndexes), which no native method takes, so it is called with none. Writes render in a microtask, so emit a focus into an if: branch from $renderedCallback, once it is drawn; showPicker() and play() with sound need a recent user gesture. /manifest publishes the table as nativeCommands, and wcs-validate and the VS Code extension check it.
表に無いメソッドは、バインディングの取り付け時に[wcs/token-misconfigured](#1205。その要素が走らせてよいメソッドを挙げる)をthrowする。意図して外したもの: HTML・属性・木を書くメソッド(代わりにhtml: / attr.をバインドする)、inputイベント無しにコントロールの値を変えるもの(form.reset()・stepUp() — 代わりにstateに書く)、検証を飛ばすform.submit()、そしてそれぞれのI/Oノードが受け持つフルスクリーン・ポインターロック・ピクチャーインピクチャーの要求。ネイティブのメソッドは、最初の引数がEventでない限りemitの引数を受け取る: on…: $command.xのバインディングは(event, ...listIndexes)をemitし、それを受け取るネイティブのメソッドは無いので、引数無しで呼ばれる。書き込みはマイクロタスクで描かれるので、if:の枝の中へのfocusは、描かれた後に$renderedCallbackからemitすること。showPicker()と音の出るplay()には直近のユーザー操作が要る。/manifestは表をnativeCommandsとして公開し、wcs-validateとVS Code拡張がそれを検査する。
The dual of command tokens: elements fire events, state receives them in $on. Signature is (state, event, ...listIndexes) — state is the first argument, not this.
コマンドトークンの双対。要素のイベントを状態が$onで受け取る。第一引数がstate。
export default {
$eventTokens: ["userCreated", "createFailed"],
$on: {
userCreated(state, event) {
state.users = state.users.concat(event.detail);
},
createFailed(state, event) {
state.error = event.detail;
}
}
};
<my-form data-wcs="eventToken.created: userCreated; eventToken.error: createFailed"></my-form>
$on handlers are never awaited. That is specified behaviour — do not sequence work on the return value; let async work write its own state slot when it settles. Since v1.24.0 a rejecting async handler is caught and reported through console.error naming the state and the handler, instead of surfacing as a bare unhandled rejection. Synchronous throws still propagate as programmer errors.
$onハンドラはawaitされない(仕様)。戻り値に処理を連ねず、非同期処理は完了時に自分で状態を書く。v1.24.0以降、rejectした非同期ハンドラは握りつぶされず、state名とハンドラ名つきでconsole.errorに報告される。同期的なthrowは従来どおり伝播する。
v2.4.0: moving the root <wcs-state> in the DOM (host.remove(); document.body.appendChild(host)) no longer silently stops $on handlers, on scans and command-token subscriptions. Disconnect used to throw both token registries away, and nothing re-subscribed on reconnect; it now keeps them, as it already kept the $stream and $watch registries. Nothing fires while the element is detached.
v2.4.0: ルートの<wcs-state>をDOM内で動かしても(host.remove(); document.body.appendChild(host))、$onハンドラ・onスキャン・コマンドトークンの購読が黙って止まることはなくなった。切断は両方のトークンレジストリを捨て、再接続で購読し直すものは無かった。いまは保持する — $streamと$watchのレジストリはすでにそうだった。要素が外れている間は何も発火しない。
$stream #v3.2.0: the canonical declaration key is $stream, singular like $watch and $scan. The old $streams works the same through 3.x and is removed in 4.0 — the key is moved onto the canonical one once, when the state enters the runtime, and declaring both spellings fails with [wcs/declaration-alias]. The $streamStatus / $streamError namespaces do not change. On 3.1 or earlier, write $streams. 4.0: $streams fails with [wcs/declaration-alias] $streams was removed: write $stream. (#1601) where the diagnostics feature is installed; on /core without it the declaration is ignored.
v3.2.0: 正規の宣言キーは$streamになった。$watchや$scanと同じく単数形だ。旧名$streamsは3.xの間は同じに動き、4.0で取り除かれる — stateがランタイムに入るときに一度だけ正規のキーへ移され、両方の綴りを宣言すると[wcs/declaration-alias]で失敗する。$streamStatus / $streamErrorの名前空間は変わらない。3.1以前では$streamsと書くこと。4.0: $streamsは、diagnostics機能が組み込まれている所では[wcs/declaration-alias] $streams was removed: write $stream.(#1601)で失敗する。それの無い/coreでは宣言は無視される。
v3.3.0: a $stream entry whose name collides with a method on the state raises when the state is set, as $scan already did. The start-time reset to initial used to overwrite the method through the proxy with no console output, and every later call failed with not a function far from the declaration. A function value the runtime placed there itself (a function initial) is not a collision.
v3.3.0: stateのメソッドと名前が衝突する$streamのエントリは、$scanがすでにそうしていたように、stateが設定されたときに投げる。開始時のinitialへのリセットはプロキシ越しにメソッドを上書きし、コンソールには何も出ず、以降の呼び出しは宣言から遠く離れた場所でnot a functionとして失敗していた。ランタイム自身が置いた関数の値(関数のinitial)は衝突ではない。
Fold async producers — async iterables, generators, ReadableStream — into reactive properties. When a dependency captured by args changes, the running source is aborted and restarted: switchMap semantics, declared.
非同期プロデューサをリアクティブなプロパティに畳み込む。argsの依存が変わると実行中のソースは中断されて再スタート(switchMap)。
export default {
prompt: "",
$stream: {
tokens: {
args: (state) => state.prompt, // dependency captured here
source: (prompt, signal) => llmStream(prompt, signal),
fold: (acc, chunk) => acc + chunk, // accumulate
initial: ""
},
ticker: {
source: (_args, signal) => priceStream(signal) // no args → start once, latest value
}
}
};
| Field | Required | Purpose |
|---|---|---|
source | ✔ | (args, signal) → AsyncIterable | ReadableStream. Must honor the AbortSignal |
args | — | Pure function capturing dependencies; omit to start once |
fold | — | (acc, chunk) → next. Omit for latest-value (replace) mode |
initial | with fold | Seed value |
<p data-wcs="textContent: $streamStatus.tokens"></p> <!-- "idle" | "active" | "done" | "error" -->
<p data-wcs="textContent: $streamError.tokens"></p> <!-- null or last error -->
To react to a stream that nothing renders, declare $watch on its value path — $renderedCallback is binding-driven and will not see it.
どこにも描画されないstreamに反応するには、値のパスに$watchを宣言する。$renderedCallbackはバインディング駆動なので届かない。
A fold accumulates within one run and returns to initial on every restart. An accumulation that has to outlive restarts — an infinite-scroll feed built across page runs — belongs in a plain key written from a $watch on the stream’s value. $scan (v2.4.0) declared exactly that; it warned in 3.5 and is removed in 4.0.
foldは1回の実行の中で蓄積し、再起動のたびにinitialへ戻る。再起動を越えて残るべき蓄積 — ページ実行をまたいで組み立てる無限スクロールのフィード — は、streamの値への$watchから書く普通のキーに置く。$scan(v2.4.0)はまさにそれを宣言するものだった。3.5で警告され、4.0で取り除かれた。
v4.0.0: what a run writes in the same task as its (re)start — a value its source yields at once, its status done / error — continues that run’s chain, for the $watch handlers it fires as for the restarts it causes. A loop through a source that yields at once is therefore cut instead of freezing the page as in 3.x: two streams whose args read each other’s values, or a $watch on a stream’s value that moves what its args reads — one lap of the latter is two links, so it stops after about 16 laps, finite or not, and auto-paging over an in-memory source stops there too (3.x completed it). A value from a later task (a network response, a message, a timer) starts afresh, so a cycle through such values restarts forever — do not build one. On the server streams keep their initial value, and a stream is the temporal feature, which the full entries include.
v4.0.0: 実行が(再)起動と同じタスクの中で書くもの — ソースがすぐに出す値、状態done / error — は、その実行の連鎖を引き継ぐ。それが起こす再起動についても、それが発火させる$watchハンドラについても同じだ。だから、すぐに値を出すソースを経るループは、3.xのようにページを止めずに打ち切られる: 互いの値をargsで読む2つのstream、あるいはstreamの値への$watchがそのargsが読むものを動かす形だ — 後者の1周は2段なので、終わるループでも終わらないループでも約16周で止まり、メモリの上のソースに対する自動ページングもそこで止まる(3.xは最後までやり遂げていた)。後のタスクから来る値(ネットワークの応答、メッセージ、タイマー)は数え直しになるので、そうした値を経る循環は永久に再起動し続ける — 作らないこと。サーバー上のstreamはinitialの値のままで、streamは完全版のエントリが載せるtemporal機能だ。
$scan (removed in 4.0) #4.0 removes $scan. v2.4.0 added it to declare an accumulation over time — an owner, a firing unit and a reset condition — and 3.5 warned about it. With the diagnostics feature (in @wcstack/state and /auto), a state that declares it fails to load with $scan was removed (use $watch or $on) (#1); on /core without it the key is ignored like any unknown $ key, and a volume that declares it is refused either way. The 4.0 lint reports it as wcs/scan-declaration-invalid (error). features/temporal now serves $watch and $stream only.
4.0は$scanを取り除く。 v2.4.0は時間にわたる蓄積 — 持ち主・発火の単位・リセット条件 — を宣言するためにそれを加え、3.5はそれを警告した。diagnostics機能があれば(@wcstack/stateと/autoは載せている)、それを宣言したstateは$scan was removed (use $watch or $on)(#1)で読み込みに失敗する。それの無い/coreでは、そのキーは未知の$キーと同じく無視され、それを宣言したボリュームはどちらでも拒まれる。4.0のlintはそれをwcs/scan-declaration-invalid(error)として報告する。features/temporalはいま$watchと$streamだけを担う。
Keep the accumulated value as an ordinary key and write it from a handler: a scan with from: "path" becomes a $watch handler on that path, a scan with on: "token" an $on handler for that event token, and resetOn a $watch handler that writes the initial value back.
蓄積した値は普通のキーとして持ち、ハンドラから書く: from: "path"のスキャンはそのパスへの$watchハンドラに、on: "token"のスキャンはそのイベントトークンの$onハンドラに、resetOnは初期値を書き戻す$watchハンドラになる。
export default {
amount: 0,
room: "lobby",
$eventTokens: ["message"],
$scan: {
total: { from: "amount", initial: 0, fold: (sum, cur) => sum + cur },
log: { on: "message", initial: [], fold: (log, e) => [...log.slice(-49), e.detail], resetOn: ["room"] },
},
};
export default {
amount: 0,
room: "lobby",
total: 0, // was $scan.total (from: "amount")
log: [], // was $scan.log (on: "message")
$eventTokens: ["message"],
$watch: {
amount(cur) { this.total = this.total + cur; },
room() { this.log = []; }, // was resetOn: ["room"]
},
$on: {
message: (state, e) => { state.log = [...state.log.slice(-49), e.detail]; },
},
};
Like a from scan, a $watch handler sees one settled value per update batch. Two things differ: the value is now plain state that you own (a re-set replaces it like any other key), and a reset happens when the $watch handler runs at the end of the batch, not at the moment of the write. Keep a fold over an infinite source bounded ([...log.slice(-99), x]), and keep an idempotency key in the accumulator where the same page can land twice (a retry, a re-attached element). The 3.x reference for $scan is in the 3.5.4 README.
fromのスキャンと同じく、$watchハンドラが見るのは更新バッチごとに確定した値1つだ。違いは2つある: 値はいまや自分が持つ素のstateであり(再セットは他のキーと同じくそれを置き換える)、リセットは書き込みの瞬間ではなく、バッチの終わりに$watchハンドラが走ったときに起きる。終わりの無いソースに対する畳み込みは上限を設け([...log.slice(-99), x])、同じページが2回着地し得る所(再試行、付け直した要素)では蓄積に冪等性のキーを持たせること。3.xの$scanの説明は3.5.4のREADMEにある。
| You want to | Use |
|---|---|
| compute from current values | a getter ($getAll(...).reduce, a wildcard getter) |
| fold within one run, where a restart may discard it | $stream fold |
| accumulate across runs and events | a plain key written from $watch (state paths) or $on (event tokens) |
| react with a side effect (emit a command, write outside) | $watch / $on |
$watch #$renderedCallback is binding-driven: it reports the paths whose live DOM bindings were applied, so a value you never render is invisible to it. $watch, added in v1.27.0, is the headless counterpart — it fires on state changes whether or not anything on the page is bound to the path.
$renderedCallbackはバインディング駆動で、ライブなDOMバインディングが適用されたパスしか報告しない。描画していない値には届かない。v1.27.0で追加された$watchはそのヘッドレス版で、パスがどこにバインドされていてもいなくても、状態の変化で発火する。
export default {
isLoading: false,
items: [],
$listKeys: { items: "id" }, // a refetch fires only the rows whose fields changed
$watch: {
// edge detection is yours: compare cur / prev
isLoading(cur, prev) {
if (cur === true && prev === false) { this.startedAt = Date.now(); }
},
// wildcard paths fire once per changed row; trailing args are loop indexes
"items.*.price"(cur, prev, index) {
this.lastPriceChange = `#${index}: ${prev} → ${cur}`;
}
}
};
The handler runs with this bound to a writable state proxy; its writes land in the next update batch, and chains of mutually-writing watches are cut at 32 links. prev is the value at the start of the batch and is scalar-only — it reuses the read the same-value guard already does, so watch costs no extra read, and it is undefined for reference types. Watching a computed getter makes it eager: evaluated at connect and at the end of every batch that touches its dependencies, so an unrendered computed can fire at all. 4.0: a watched getter’s prev is its previous evaluation, and a watched wildcard getter (items.*.tax) is evaluated for every row of its list, rendered or not — keep it cheap and free of side effects. Handler exceptions are isolated — reported to the console (and the devtools timeline), while the remaining watches still run. SSR never runs watches.
ハンドラのthisは書き込み可能なstateプロキシで、書き込みは次の更新バッチに乗る。相互に書き合うwatchの連鎖は32段で打ち切られる。prevはバッチ開始時点の値でスカラー限定 — same-value guardが既に行う読み取りを再利用するため追加コストがなく、参照型ではundefinedになる。算出getterをwatchするとeagerになり、接続時と依存が動いたバッチの終わりに毎回評価される — 描画されていない算出値が発火できるのはこのため。4.0: watchしたgetterのprevはその前回の評価で、watchしたワイルドカードのgetter(items.*.tax)は、描かれていてもいなくてもリストの全行について評価される — 軽く、副作用の無いものに保つこと。ハンドラ内の例外は隔離され、コンソール(とdevtoolsタイムライン)に報告された上で残りのwatchは実行される。SSRではwatchは動かない。
| Rule | Consequence |
|---|---|
| Paths of the tree only | A key may not contain @ (the v1 name selector) — rejected at declaration |
No $-prefixed keys | $streamStatus.x cannot be watched directly — mirror it through a one-line non-$ getter and watch that (the eager rule makes it work unrendered) |
| Batches coalesce | a → b → c in one batch fires once with cur = c, prev = a |
| A row watch is headless (4.0) | It needs no for: and no $listKeys: 4.0 keeps the lists it ranges over in step itself (3.x fired it zero times without either). Assigning an array fires the rows that entered the list, matched by identity, with prev === undefined; a row the assignment kept does not fire, and a re-sort fires nothing. With $listKeys, a refetch of fresh objects decomposes into per-field writes, so only changed rows fire, with a real prev |
| Row getters are eager (4.0) | A watch on items.*.tax evaluates the getter for every row, rendered or not; a kept row fires only when its value changed, unless the batch changed something it read outside its row (now, items.length) or wrote the same path in another row (#389). 3.x fired it only where it was also DOM-bound |
| Rows fire as the list stands | A wildcard watch fires once per changed position as the list stands at the drain (v2.4.0): a row written and then removed, replaced or cut off in the same job does not fire, and a row that only moved does not either |
| Indexes and shared arrays (4.0) | A key with a numeric index ("items.0.v") fires only when the value at that index changes. Writing an element of an array several outer rows share fires groups.*.items.* once for every outer row that holds it, each with its own indexes |
Mounted bind-component scopes excluded | A mounted scope does not run $watch — the declaration is ignored with a one-time wcs/mount-dollar-declaration warning (same for $stream and $renderedCallback). Declare it on the root; in 4.0 a volume refuses it too |
v3.4.0: there are two chain limits now. The $watch chain is counted per write: a handler, a $scan fold or a $stream restart fires at the depth of the write that triggered it, so a handler that follows a long finite render chain (a $renderedCallback shrinking a heading one step per render) is no longer cut off at the 34th value, while mutually-writing handlers — and a loop through a $stream restart, which used to run without end — are still cut after 32 links. And a chain of writes made while bindings are applied — a row element handing its initial value to state, a $renderedCallback write — is cut after 100 links: the batch past the limit is not rendered (its values stay in the state), one console.error names its paths (render chain depth limit exceeded; bindings for this batch were not applied.), and DevTools receives state:render-chain-limit, which its timeline shows as a render-chain-limit warning row. Writes from $watch, $scan and $stream restarts carry that chain over, so a render loop through them is cut too, and finite chains joined that way share the 100 links. A write from outside the drain — a user action, a timer, a $stream value, an await continuation — starts from zero, so an element that reports from a microtask and an async $renderedCallback stay outside the limit. An element swap whose two writes run in separate updates now fires items.* for the position the completing update wrote, with that position’s prev. 4.0: past the 32-link limit only the handlers and restarts those writes fired are skipped, and the batch’s other handlers still run (3.x skipped the whole batch); a restart’s writes count toward the render chain too, so element write-backs that restart a stream a microtask apart are cut after about 50 renders. And a single update that keeps producing work within itself is stopped after 32 passes and reported once (updates did not settle after 32 passes; DevTools: state:render-chain-limit with maxDepth: 32). The render chain now also counts an element that reports its value from a microtask (Lit’s updated()) and a $renderedCallback until it settles, after an await included, so the loops through them that 3.x left outside the limit are cut instead of freezing the page (#353).
v3.4.0: 連鎖の上限はいま2つある。$watchの連鎖は書き込みごとに数えられる: ハンドラ、$scanのfold、$streamの再起動は、それを起こした書き込みの深さで発火するので、長いが有限の描画の連鎖(描画ごとに見出しを1段ずつ縮める$renderedCallback)を追うハンドラが34個目の値で打ち切られることはもう無く、相互に書き合うハンドラ — そして終わりなく回っていた、$streamの再起動を経るループ — は今も32段で打ち切られる。そしてバインディングの適用中に起きる書き込みの連鎖 — 行の要素がstateに初期値を渡す、$renderedCallbackが書く — は100段で打ち切られる: 上限を超えたバッチは描画されず(値はstateに残る)、console.errorが1つそのパスを挙げ(render chain depth limit exceeded; bindings for this batch were not applied.)、DevToolsはstate:render-chain-limitを受け取って、タイムラインにrender-chain-limitの警告行として示す。$watch・$scan・$streamの再起動からの書き込みはその連鎖を引き継ぐので、それらを経る描画のループも打ち切られ、そうしてつながった有限の連鎖は100段を分け合う。ドレインの外から来た書き込み — ユーザーの操作、タイマー、$streamの値、awaitの続き — は0から数え直すので、マイクロタスクから値を報告する要素や非同期の$renderedCallbackは上限の外にとどまる。2つの書き込みが別々の更新で走った要素のswapは、完了させた更新が書いた位置について、その位置のprevとともにitems.*を発火するようになった。4.0: 32段の上限を越えたとき飛ばされるのは、その書き込みが発火させたハンドラと再起動だけで、バッチの他のハンドラは走る(3.xはバッチ全体を飛ばしていた)。再起動の書き込みも描画の連鎖に数えられるので、マイクロタスクおきにstreamを再起動させる要素の書き戻しは約50回の描画で打ち切られる。そして自分の中で仕事を生み続ける1つの更新は、32回のパスで止められ一度報告される(updates did not settle after 32 passes。DevToolsにはmaxDepth: 32付きのstate:render-chain-limit)。描画の連鎖はいま、マイクロタスクから値を報告する要素(Litのupdated())と、決着するまでの$renderedCallback(awaitの後も含む)も数えるので、3.xが上限の外に置いていたそれらを経るループは、ページを止めずに打ち切られる(#353)。
Tooling follows the declaration: @wcstack/lint and the VS Code extension check every $watch key statically (wcs/watch-declaration-invalid, wcs/watch-path-missing) — worth heeding, because a $watch typo does not fail visibly the way a binding typo does; it just never fires. The devtools timeline records watch-error and watch-chain-limit events, and as of v1.29.0 firing itself is measured: the runtime emits state:watch-fired, and the devtools coverage tab joins declared keys against it — each path shows fired ×N, never, or prerequisite-missing, kept distinct from “never”. Since v1.30.0 that prerequisite is exact: a wildcard’s list counts as satisfied when it is for-bound or $listKeys-declared — the same two conditions the rules table states — and when neither holds the note says assertively that the watch can never fire. Since v1.28.0 the runtime’s own declaration errors carry the same [wcs/watch-declaration-invalid] code plus a lint pointer, so console and CLI speak one vocabulary. 4.0: that prerequisite was 3.x’s, so on a 4.0 page the coverage view shows a row $watch without a for: or $listKeys as never until it fires, and keeps prerequisite-missing for a 3.x runtime.
ツーリングも宣言を追う。@wcstack/lintとVS Code拡張は$watchのキーを静的に検証する(wcs/watch-declaration-invalid・wcs/watch-path-missing)。バインディングのタイプミスは「描画されない」形で見えるが、$watchのタイプミスは黙って一度も発火しないので、この検証は素通りしないこと。devtoolsのタイムラインにはwatch-errorとwatch-chain-limitが記録され、v1.29.0からは発火そのものが実測される。ランタイムがstate:watch-firedを発行し、devtoolsのcoverageタブが宣言キーと突き合わせる — 各パスはfired ×N・never・prerequisite-missing(「never」とは区別される)のいずれかを示す。v1.30.0からこの前提条件は正確になった。ワイルドカードのリストはforにバインドされているかまたは$listKeys宣言があれば満たされる — ルール表が述べるのと同じ2条件 — そしてどちらもないとき、ノートは「このwatchは発火し得ない」と言い切る。v1.28.0以降はランタイム自身の宣言エラーも同じ[wcs/watch-declaration-invalid]コードとlintへの誘導を運ぶので、コンソールとCLIが同じ語彙を話す。4.0: この前提条件は3.xのものだったので、4.0のページではcoverageビューはfor:も$listKeysも無い行の$watchを、発火するまでneverと示し、prerequisite-missingは3.xのランタイムにだけ残す。
bind-component="state" binds the element's own state field as the state source.
class MyComponent extends HTMLElement {
state = { message: "" };
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<wcs-state bind-component="state"></wcs-state>
<div>{{ message }}</div>
<input type="text" data-wcs="value: message" />
`;
}
}
customElements.define("my-component", MyComponent);
In v2 a Light DOM component is written exactly like the Shadow one — no name, no @ references. Scope is decided by position in the DOM, so the v1 rule “two instances carrying the same name cannot share a scope, use Shadow DOM for a component stamped on every row” is gone with the registry that caused it: the same Light DOM component can sit on every row of a list. The host must wire it, though — a plain, unwired Light DOM bind-component cannot exist in v2, because an independent tree cannot share the parent’s root; it fails loudly with the fix (attach a shadow root, or mount it from the host). getBindingsReady(root) now covers mounted scopes once the mount record resolves.
v2ではLight DOMコンポーネントはShadowと完全に同じ書き方になる — nameも@参照も要らない。スコープはDOM上の位置で決まるので、「同じnameの2インスタンスは1スコープに共存できない、リストの各行に置くならShadow DOMを使え」というv1の制約は、その原因だったレジストリごと消えた。同じLight DOMコンポーネントをリストの全行に置ける。ただしホストが必ず配線すること — 配線のない素のLight DOM bind-componentはv2では存在できない(独立した木は親のルートを共有できないため)。修正方法つきで大きく失敗する(shadow rootを付けるか、ホストからマウントする)。getBindingsReady(root)はマウント記録が解決すればマウント配下も覆う。
class MyLightComponent extends HTMLElement {
state = { message: "" };
connectedCallback() {
this.innerHTML = `
<wcs-state bind-component="state"></wcs-state>
<div data-wcs="text: message"></div>
<input type="text" data-wcs="value: message" />
`;
}
}
customElements.define("my-light-component", MyLightComponent);
// the host wires it: <my-light-component data-wcs="state: user">
// or <my-light-component data-wcs="state.message: user.name">
The parent page binds into a component's state from outside — still just paths.
<wcs-state>
<script type="module">
export default { user: { name: "Alice" } };
</script>
</wcs-state>
<my-component data-wcs="state.message: user.name"></my-component>
A standalone component can await readiness with async $stateReadyCallback(stateProp).
state: pathInstead of wiring property by property, mount a whole subtree as the component’s root — inside the component every path is relative to the mount point: <user-card data-wcs="state: user"> makes name inside be user.name (reads, writes, getters and for: included). In a loop, mount the row itself: <user-row data-wcs="state: .">. Partial mounts coexist by longest prefix (state: user; state.theme: theme), and the component’s unmapped own keys stay private (rule R1). An array cannot be the mount root — mount the row or the object holding the array. 3.0: an explicit partial mount wins over the component’s own key — a component that declares a default for a mapped key (state = { message: "" } with state.message: user.name) reads the host value, and the default is simply not used (in 2.x the default won, with a wcs/mount-own-key-shadow warning). And #ro on a mount is honoured: with state#ro: user or state.title#ro: doc.title the component can read the entry but not write it — an assignment, $setAll or a writing $resolve throws [wcs/mount-readonly], and a two-way input inside does not write back, while the host can still write the path (2.x accepted the modifier and ignored it).
プロパティを1つずつ結線する代わりに、サブツリー全体をコンポーネントのルートとしてマウントする — 内側ではすべてのパスがマウント点からの相対になる。<user-card data-wcs="state: user">なら内側のnameはそのままuser.name(読み・書き・getter・for:を含む)。ループでは行そのものをマウントする: <user-row data-wcs="state: .">。部分マウントは最長プレフィクス勝ちで共存し(state: user; state.theme: theme)、コンポーネント自身のマップされていないキーはprivateのまま(規則R1)。配列をマウントルートにはできない — 行か、配列を持つオブジェクトをマウントする。3.0: 明示的な部分マウントはコンポーネント自身のキーに勝つ — マップされたキーに既定値を宣言したコンポーネント(state.message: user.nameとstate = { message: "" })はホストの値を読み、既定値は単に使われない(2.xでは既定値が勝ち、wcs/mount-own-key-shadowで警告された)。そしてマウントの#roが守られる: state#ro: userやstate.title#ro: doc.titleなら、コンポーネントはその項目を読めるが書けない — 代入・$setAll・書き込みの$resolveは[wcs/mount-readonly]をthrowし、中の双方向の入力も書き戻さない。ホストは今もそのパスに書ける(2.xは修飾子を受け取って無視していた)。
v3.3.0: only a one-segment entry marks a component key as mapped, so a deep partial mount (state.a.b: outer.b) no longer makes a.c read the host. A component’s own method wins over a partial mount of the same name again (state.save: x had made save() uncallable), and a partial entry the component’s own surface hides is reported once as wcs/mount-own-key-shadow. A class-based component keeps its prototype accessors: a getter whose name also exists on the host tree under the mount point now wins inside the component, as it already did for an object-literal state.
v3.3.0: コンポーネントのキーをマップ済みにするのは1区間のエントリだけになったので、深い部分マウント(state.a.b: outer.b)がa.cにホストを読ませることはもう無い。コンポーネント自身のメソッドは同名の部分マウントに再び勝ち(state.save: xはsave()を呼べなくしていた)、コンポーネント自身の面が隠す部分エントリはwcs/mount-own-key-shadowとして一度報告される。クラスで書いたコンポーネントはプロトタイプのアクセサを保つ: マウント点の下のホストの木にも同じ名前があるgetterは、オブジェクトリテラルのstateですでにそうだったように、いまはコンポーネントの中で勝つ。
v3.4.0: a mounted component behaves the same whichever way it is reached. A method that writes one of the component’s private keys (toggle() { this.mode = … }) re-renders in that pass — since 2.0 the value changed but {{ mode }} and the getters over it kept the old one until an unrelated render, a regression from 1.x — so a $postUpdate added as a workaround is now redundant. A method taken from element.state (card.state.toggle()) runs as it does from onclick: toggle: before, it ran read-only and outside the row, so a private write did not render and a tree write threw This state is readonly. A getter of a component partially mounted in a for: row (state.message: .name) can read the mounted keys, and $getAll with the indexes omitted means the instance’s own row instead of mixing every row. In a row, a component reads and writes its own row from its methods, after an await, and from $connectedCallback / $disconnectedCallback — also after its row’s content is reused from the pool and while an if: in the row hides it, where it used to throw ListIndex not found or read a stale value. The hooks’ this is one object per connection and follows the element’s row, and once that row has been removed, or the element reused for another row, a tree-key access throws The host row of <x-row> was removed. instead of reaching whichever row now sits at the old position. A removed row’s $disconnectedCallback now reads and writes the component’s own keys — clear the timer id kept in this.tid there.
v3.4.0: マウントされたコンポーネントは、どの経路から届いても同じに振る舞う。コンポーネントの私有キーに書くメソッド(toggle() { this.mode = … })はその回で再描画する — 2.0からは値が変わっても{{ mode }}とその上のgetterが無関係な描画まで古い値を保っていた。1.xからの退行だ — なので回避策として足した$postUpdateはもう要らない。element.stateから取ったメソッド(card.state.toggle())はonclick: toggleから呼んだときと同じに走る: 以前は読み取り専用で行の外で走ったので、私有キーへの書き込みは描画されず、木への書き込みはThis state is readonly.をthrowした。for:の行に部分マウント(state.message: .name)したコンポーネントのgetterはマウントされたキーを読め、インデックスを省いた$getAllはすべての行を混ぜる代わりにそのインスタンス自身の行を意味する。行の中のコンポーネントは、メソッドから、awaitの後で、$connectedCallback / $disconnectedCallbackから自分の行を読み書きする — 行の中身がプールから再利用された後も、行の中のif:がそれを隠している間も同じで、以前はそこでListIndex not foundをthrowするか古い値を読んでいた。フックのthisは接続ごとに1つのオブジェクトで、要素の行に付いていき、その行が取り除かれた後や要素が別の行に再利用された後の木のキーへのアクセスは、古い位置にいまいる行に届く代わりにThe host row of <x-row> was removed.をthrowする。取り除かれた行の$disconnectedCallbackはいまコンポーネント自身のキーを読み書きできる — this.tidに保ったタイマーのidはそこで止めること。
v3.5.2: a component scope initialized again on the same DOM works like the first one. That happens to a render-once component that swaps only its <wcs-state bind-component>, and to one moved (or removed and re-inserted) before its <wcs-state> finished initializing. The collection skipped the nodes it had already registered, so data-wcs, {{ }}, events and for / if in the scope stopped following the state with nothing reported; the nodes still in the scope are bound again now (a discarded DOM stays disposed), and a binding that fails to start again is reported with console.error. A move during initialization built the scope twice and ran $connectedCallback twice for one connection; the newer connection now takes the preparation over. And the mustache conversion ran again over the rendered scope, reading text a binding had written as markup: a value containing {{ path }} became a live binding resolved in the component scope, its private keys included, while the {{ }} that rendered it stopped updating. That is template injection — upgrade if a component renders untrusted data. On a re-initialization, text under a bound node is left as data; elements with data-wcs inside an innerHTML: binding’s output are still collected. 4.0: a value is never read as markup in the first place, and a new <wcs-state bind-component> that replaces the old one while nodes the old one bound or rendered are still there takes the scope over: those bindings, rows and {{ }} text stay as they are, and nodes with bindings added beside them are not bound (a console.warn says so); when the content is rendered again with it, the new element binds it afresh.
v3.5.2: 同じDOMのまま初期化し直されたコンポーネントのスコープは、最初のものと同じに動く。これが起きるのは、<wcs-state bind-component>だけを差し替える一度だけ描くコンポーネントと、<wcs-state>の初期化が終わる前に移動された(あるいは取り除かれて差し戻された)コンポーネントだ。収集はすでに登録したノードを飛ばしたので、スコープの中のdata-wcs・{{ }}・イベント・for / ifは何も告げずにstateに追従しなくなっていた。いまはスコープに残ったノードがもう一度バインドされ(捨てたDOMは捨てたまま)、もう一度始められなかったバインディングはconsole.errorで報告される。初期化中の移動はスコープを2回組み立て、1つの接続に対して$connectedCallbackを2回走らせていた。いまは新しい接続が準備を引き取る。そしてmustacheの変換が描画済みのスコープの上でもう一度走り、バインディングが書いたテキストをマークアップとして読んでいた: {{ path }}を含む値はコンポーネントのスコープで解決される生きたバインディングになり — 私有キーも含めて — それを描いた{{ }}は更新を止めた。テンプレートインジェクションだ — 信頼できないデータを描くコンポーネントがあれば上げること。初期化し直すとき、バインドされたノードの下のテキストはデータのまま残る。innerHTML:バインディングの出力の中のdata-wcsを持つ要素は今も集められる。4.0: そもそも値がマークアップとして読まれることは無く、古い<wcs-state bind-component>がバインドしたり描いたりしたノードが残っている間にそれを置き換えた新しい要素はスコープを引き継ぐ: それらのバインディング・行・{{ }}のテキストはそのままで、その脇に加えたバインディングを持つノードはバインドされない(console.warnがそう告げる)。中身がそれと一緒に描き直されたときは、新しい要素がそれを改めてバインドする。
v4.0.0: a mounted component runs what belongs to its own bindings. $listKeys keys its own lists, $commandTokens / $eventTokens / $on run on its own bindings, $errorCallback receives the failures of its own bindings, and it declares its own $behavior / $features — nothing is inherited from the host. 3.x ran none of them and warned. $watch, $stream and $renderedCallback are still not run (one wcs/mount-dollar-declaration warning), and $recursion / ** getters now throw [wcs/mount-dollar-declaration]. An element write of the host row (this["users.1"] = obj) keeps the component element and its private keys, while what it reads of the row is the new value (#385, #386), and full-mount (state: .) row components keep their private keys per element, so replacing the list is no longer quadratic. A host the page did not bind — taken out before the page’s state loaded, or inserted later by code rather than by for: / if: or the router — is wired when its <wcs-state bind-component> connects (3.x waited forever). The component’s state is the host’s property, so a <wcs-state bind-component> with state, src, json or an inline script is refused, and so is a second connected one in one component; a host wiring added after the component loaded its state throws.
v4.0.0: マウントされたコンポーネントは、自分のバインディングに属するものを動かす。$listKeysは自分のリストを鍵付けし、$commandTokens / $eventTokens / $onは自分のバインディングで動き、$errorCallbackは自分のバインディングの失敗を受け取り、自分の$behavior / $featuresを宣言する — ホストから受け継ぐものは無い。3.xはどれも動かさずに警告していた。$watch・$stream・$renderedCallbackは今も動かず(wcs/mount-dollar-declarationの警告1つ)、$recursion / **のgetterはいま[wcs/mount-dollar-declaration]をthrowする。ホストの行の要素への書き込み(this["users.1"] = obj)はコンポーネントの要素とその私有キーを保ち、行から読むものは新しい値になる(#385、#386)。行そのものをマウントした(state: .)コンポーネントは私有キーを要素ごとに保つので、リストの置き換えはもう二乗にならない。ページがバインドしなかったホスト — ページのstateが読み込まれる前に取り出されたもの、for: / if:やルーターではなくコードが後から差し込んだもの — は、その<wcs-state bind-component>が接続したときに配線される(3.xは永久に待っていた)。コンポーネントのstateはホストのプロパティなので、state・src・json・インラインのスクリプトを持つ<wcs-state bind-component>は拒まれ、1つのコンポーネントで接続した2つ目のものも拒まれる。コンポーネントがstateを読み込んだ後に加えたホストの配線はthrowする。
Exported getters (v2.2.0). A mounted component’s getters are exported at its mount point: a read of a key the tree does not have is answered by the getter of the component mounted there — <span data-wcs="textContent: user.display"> renders the component’s get display(). A key the tree does have wins and warns once (wcs/mount-export-shadowed); private keys and methods are never visible. Row mounts export per row ($getAll("users.*.display")), dependencies flow through, and writing to an exported key runs the setter or throws. Accessors whose component-local path contains a wildcard are not exported. This is what makes self-recursive components expressible: a <tree-node> rendering <tree-node data-wcs="state: ."> per child can define get total() over this.$getAll("children.*.total"). The parent evaluates before the child registers, so write derived expressions defensively ((x ?? 0)).
エクスポートされたgetter(v2.2.0)。 マウントされたコンポーネントのgetterはマウント点にエクスポートされる: 木に無いキーの読みは、そこにマウントされたコンポーネントのgetterが答える — <span data-wcs="textContent: user.display">はコンポーネントのget display()を描画する。木にあるキーは木が勝ち、一度だけ警告する(wcs/mount-export-shadowed)。私有キーとメソッドは決して見えない。行マウントは行ごとにエクスポートし($getAll("users.*.display"))、依存は貫通し、エクスポートされたキーへの書き込みはsetterを走らせるか無ければthrowする。コンポーネント内のパスにワイルドカードを含むアクセサはエクスポートされない。これが自己再帰コンポーネントを可能にする: 子ごとに<tree-node data-wcs="state: .">を描く<tree-node>が、this.$getAll("children.*.total")の上にget total()を定義できる。親は子の登録より先に評価されるので、派生式は防御的に書く((x ?? 0))。
Define a reusable custom element entirely in HTML with data-wc-definition. Each instance gets its own state; $bindables declares the properties exposed through the wc-bindable protocol (with CustomEvent dispatch like my-counter:count-changed).
data-wc-definitionでHTMLだけの再利用可能コンポーネントを定義。各インスタンスは独立した状態を持ち、$bindablesで公開プロパティを宣言。
<my-counter data-wc-definition>
<template shadowrootmode="open">
<p>{{ count }}</p>
<button data-wcs="onclick: increment">+1</button>
<wcs-state>
<script type="module">
export default {
count: 0,
increment() { this.count++; },
$bindables: ["count"]
};
</script>
</wcs-state>
</template>
</my-counter>
<my-counter></my-counter>
<my-counter></my-counter>
Since v1.26.0, $commands does for methods what $bindables does for properties: each listed method becomes a commands entry in the generated manifest, so a parent can drive it with a command token exactly like an I/O node. Command entries are always async: true — a DCC method chains on the inner <wcs-state>'s initialization, so it returns a Promise whether or not it was written async.
v1.26.0以降、$commandsはメソッドに対して$bindablesと同じ役割を果たす。列挙したメソッドはマニフェストのcommandsエントリになり、親からI/Oノードと同じくコマンドトークンで駆動できる。エントリは常にasync: true。DCCのメソッドは内側の<wcs-state>の初期化に連なるため、asyncで書いたかどうかに関わらずPromiseを返す。
<!-- inside the definition's template -->
<wcs-state>
<script type="module">
export default {
count: 0,
bumpBy(step) { this.count += step; },
$bindables: ["count"],
$commands: ["bumpBy"]
};
</script>
</wcs-state>
<!-- the parent drives it; emit(3) calls bumpBy(3) -->
<my-counter data-wcs="count: parentCount; command.bumpBy: $command.bump"></my-counter>
Both declarations are validated when the component is defined. A duplicated entry — which used to fail silently, leaving the element quietly no longer two-way bindable — now raises, as do an entry starting with $, an entry that does not exist on the state, a method listed in $bindables, and a value property listed in $commands.
どちらの宣言もコンポーネント定義時に検証される。重複エントリ(従来は無言で失敗し、要素が双方向バインド不能になっていた)、$で始まるエントリ、stateに存在しないエントリ、$bindablesに挙げたメソッド、$commandsに挙げた値プロパティは、いずれもエラーになる。
DCC and bind-component both give a custom element its own state, and they are mutually exclusive — pick one per component. The rule of thumb: if the component has no JavaScript class, use DCC; if you are already writing a class, use bind-component. Combining them raises: a <wcs-state bind-component> inside a data-wc-definition host is a configuration error, because DCC state belongs to the template and is loaded per instance.
DCCとbind-componentはどちらもカスタム要素に独自の状態を与えるが、排他であり、コンポーネントごとにどちらか一方を選ぶ。目安は、JavaScriptのクラスを書かないならDCC、すでにクラスを書いているならbind-component。併用はエラーになる。data-wc-definitionホストの中の<wcs-state bind-component>は設定エラーで、DCCの状態はテンプレートに属しインスタンスごとに読み込まれるため。
| DCC | bind-component | |
|---|---|---|
| How the element is defined | HTML only (data-wc-definition + DSD) | A JS class extends HTMLElement you write |
| Where the state lives | An inline <script type="module"> in the template, per instance | A property on the instance (this.state) |
static wcBindable | Generated from $bindables / $commands | None — not a wc-bindable producer |
| Parent binds a value | count: parentCount (two-way) | state.msg: user.name (path mapping) |
| Parent invokes a method | command.bumpBy: $command.bump | Not available — expose it on the class |
Spread (...: obj) | Available | Not available (needs a manifest) |
bind-component components deliberately stay outside the wc-bindable protocol: they are wired by path, not by a declared property surface. That is why spread and command tokens, which both need a manifest, do not apply to them. A bind-component child placed inside a parent for: can, since v1.26.0, iterate a list handed to it from that row with a for: of its own — and v1.27.0 extends this to any depth and stacking: components inside components stack scopes, the base list index composes across every boundary, and an intermediate component that only passes the array through still delivers row-field writes to the rows at the bottom. Loop indexes stay scope-local ($1, handler indexes, $getAll all report positions within the component's own scope), so a component's author never has to know how deeply it is placed. One consequence of mounting: a mounted scope does not run the declarations that react to the whole tree, so $watch, $stream and $renderedCallback there are ignored with a one-time warning — declare them on the root state (in 4.0 a volume refuses them too).
bind-componentのコンポーネントは意図的にwc-bindableプロトコルの外にいる。宣言されたプロパティ面ではなくパスで結線されるため、マニフェストを要するspreadとコマンドトークンは適用されない。v1.26.0以降、親のfor:の中に置いたbind-componentの子は、その行から渡されたリストを自分のfor:で回せる。v1.27.0はこれを任意の深さと積み重ねに拡張した。コンポーネントの中のコンポーネントはスコープを積み、基底のリストインデックスは境界ごとに合成され、配列を素通しするだけの中間コンポーネントがあっても行フィールドへの書き込みは最下層の行まで届く。ループインデックスはスコープ局所のまま($1・ハンドラのインデックス・$getAllはすべて自スコープ内の位置を報告する)なので、コンポーネントの作者は自分がどの深さに置かれるかを知らなくてよい。マウントの帰結として、マウントされたスコープは木全体に反応する宣言を動かさないので、そこでの$watch・$stream・$renderedCallbackは一度の警告とともに無視される — ルートのstateに宣言すること(4.0ではボリュームもそれを拒む)。
Custom elements expose a static manifest — observable properties, settable inputs, invocable commands — so any binding core can discover and wire them generically. All 30+ wcstack I/O nodes speak it; so can yours.
静的マニフェスト(properties / inputs / commands)を公開すれば、どのバインディングコアからも汎用的に結線できる。
class MyChip extends HTMLElement {
static wcBindable = {
protocol: "wc-bindable",
version: 1,
properties: [
{ name: "data", event: "thing-error" }
],
inputs: [
{ name: "data", attribute: "data" },
{ name: "labelText", attribute: "label-text" },
{ name: "internal" }
],
commands: [
{ name: "fetch", async: true },
{ name: "reset" }
]
};
}
attribute hint (4.0: property only)4.0 writes an input’s property, and only the property. An entry’s optional attribute names the HTML attribute that corresponds to the property in markup (<my-chip label-text="…">); the protocol defines it as a declaration for tooling, and reflecting a property to its attribute — for attributeChangedCallback, CSS attribute selectors or DevTools — is the element’s job, in its setter, with the encoding only the element knows (a boolean as a present / absent attribute, "on" / "off", JSON …). This is wc-bindable 0.10’s applier profile, A1. 3.x also wrote the attribute after the property (String(value), JSON for an object or array, removed for null / undefined), and that copy fought the element’s own: a false written as "false" turned on the I/O nodes that read the attribute by its presence (<wcs-wakelock active>, <wcs-camera keep-alive>, the manual of the I/O nodes), and an attribute the element changed itself stayed stale. Every wcstack element reflects in its setter, so nothing changes for them; an element of your own that has no setter, or reads the input only in attributeChangedCallback, needs a setter that reflects it, and CSS that matched an attribute value state wrote ([active="false"]) now sees the element’s own encoding.
4.0は入力のプロパティに、プロパティにだけ書く。 エントリの省略可能なattributeは、マークアップでそのプロパティに対応するHTMLの属性を名指すもの(<my-chip label-text="…">)で、プロトコルはそれをツールのための宣言と定めている。プロパティを属性へ反映するのは — attributeChangedCallbackやCSSの属性セレクタやDevToolsのために — 要素の仕事で、setterの中で、要素だけが知る符号化(真偽値なら属性の有無、"on" / "off"、JSON…)で行う。wc-bindable 0.10のapplierプロファイルのA1だ。3.xはプロパティの後で属性にも書いていた(String(value)、オブジェクトや配列はJSON、null / undefinedなら取り除く)が、その写しは要素自身のものとぶつかっていた: "false"と書かれたfalseは、属性の有無で読むI/Oノード(<wcs-wakelock active>、<wcs-camera keep-alive>、I/Oノードのmanual)をオンにし、要素が自分で変えた属性は古いまま残った。wcstackの要素はどれもsetterで反映するので、それらには何も変わらない。setterを持たない自作の要素や、入力をattributeChangedCallbackでしか読まない要素には、それを反映するsetterが要る。stateが書いた属性の値に合わせていたCSS([active="false"])は、いまは要素自身の符号化を見る。
class MyChip extends HTMLElement {
static wcBindable = {
protocol: "wc-bindable", version: 1,
properties: [],
inputs: [
{ name: "labelText", attribute: "label-text" }, // markup: <my-chip label-text="…">
{ name: "selected", attribute: "selected" }, // markup: <my-chip selected>
],
};
get labelText() { return this.getAttribute("label-text") ?? ""; }
set labelText(v) { v == null ? this.removeAttribute("label-text") : this.setAttribute("label-text", v); }
get selected() { return this.hasAttribute("selected"); }
set selected(v) { this.toggleAttribute("selected", Boolean(v)); }
}
null and undefined on an I/O node’s input (4.0). Every wcstack I/O node handles both as wc-bindable 0.10’s producer guidance asks: null removes the input’s attribute (its documented default), undefined restores the attribute the element started with (the markup value, or none), and neither is written as the string "null" / "undefined" — 3.x’s String(value) made <wcs-sse>, <wcs-ws> and <wcs-worker> open undefined. @wcstack/state never writes undefined to an input (an undefined path skips the write, stricter than the profile’s A2), so a page bound with it sees a difference only where it writes null: a boolean that is on by default (<wcs-audio>’s limiter, <wcs-debounce>’s trailing, <wcs-throttle>’s leading) goes back on instead of off.
I/Oノードの入力へのnullとundefined(4.0)。 wcstackのI/Oノードはどれも、wc-bindable 0.10のプロデューサー向けの指針どおりに両方を扱う: nullは入力の属性を取り除き(文書にある既定値)、undefinedは要素が最初に持っていた属性(マークアップの値、または無し)を戻し、どちらも文字列"null" / "undefined"としては書かれない — 3.xのString(value)は、<wcs-sse>・<wcs-ws>・<wcs-worker>にundefinedを開かせていた。@wcstack/stateは入力にundefinedを決して書かない(undefinedのパスは書き込みを飛ばす。プロファイルのA2より厳しい)ので、それでバインドしたページに違いが見えるのはnullを書く所だけだ: 既定でオンの真偽値(<wcs-audio>のlimiter、<wcs-debounce>のtrailing、<wcs-throttle>のleading)は、オフではなくオンに戻る。
#init= / #sync=)For wc-bindable elements, the modifier decides which side owns the wire:
| Authority | Effect |
|---|---|
init=state | State writes to element; element events update state (two-way) |
init=element | Element snapshot + events → state; state writes suppressed |
init=auto | Element-owned if uninitialized, else state-owned |
init=none | No initial sync (event bindings only) |
sync=call (default) reads the snapshot immediately; sync=connect defers until DOM connection.
Directional initial sync rule: declare every settable member in both properties and inputs for two-way. Output-only members declared only in properties become element-owned — state writes are suppressed. The rule is on by default; in 4.0 a state tree opts out with $behavior: { enableDirectionalInitialSync: false } (it was a bootstrapState() option), and then #init= / #sync= throw (#31) and state wins every initial sync.
双方向にしたいメンバーはpropertiesとinputsの両方に宣言する。propertiesのみのメンバーは出力専用(要素側が所有)。この規則は既定で有効で、4.0ではstateの木ごとに$behavior: { enableDirectionalInitialSync: false }で外す(以前はbootstrapState()のオプションだった)。そのとき#init= / #sync=はthrowし(#31)、初期同期はすべてstateが勝つ。
v3.4.0: an element-owned member in a for: row takes its initial value from its own element again — on a render whose rows were all new it threw and left the list empty (see Loop). Inside a shadow root with a scoped CustomElementRegistry, the elements of for: rows take their definitions from that registry: the first rows of a render were activated in a DocumentFragment, where Chromium reports the global registry, so their property bindings and spreads stayed empty until the same name was defined globally, and the listeners of two-way and output members never reached state — as in 3.3.0. An #init=element or output-only member also keeps the element’s own value in a row added later and in an if: branch.
v3.4.0: for:の行の中の要素所有のメンバーは、再び自分の要素から初期値を受け取る — 行がすべて新しい描画では、throwしてリストを空にしていた(Loopを参照)。スコープ付きのCustomElementRegistryを持つshadow rootの中では、for:の行の要素はそのレジストリから定義を受け取る: 描画の最初の行はDocumentFragmentの中で起動され、Chromiumはそこでグローバルのレジストリを返すので、同じ名前がグローバルに定義されるまでそのプロパティバインディングとspreadは空のままで、双方向や出力のメンバーのリスナーはstateに届かなかった — 3.3.0でも同じだ。#init=elementや出力専用のメンバーは、後から追加された行やif:の枝でも、要素自身の値を保つ。
Add enable-ssr to <wcs-state> and render the same HTML with @wcstack/server. The client hydrates automatically from the <wcs-ssr> JSON snapshot and skips $connectedCallback — zero flicker.
enable-ssrを付けて同じHTMLをサーバーでレンダリング。クライアントは<wcs-ssr>スナップショットから自動ハイドレーションし、$connectedCallbackはスキップされる。
import { renderToString } from "@wcstack/server";
const html = await renderToString(template, {
baseUrl: "http://localhost:3000"
});
v2.3.0: a snapshot no longer evaluates the state object’s own enumerable getters (a { get count() { … } } in the object literal). They were evaluated with the raw object as this, so a path getter serialized as null and a getter calling $getAll threw and took the whole page’s SSR down. Derived values are recomputed on the client from the same definition; a consumer that read a getter’s value out of the snapshot should read the data it derives from instead.
v2.3.0: スナップショットはstateオブジェクト自身の列挙可能なgetter(オブジェクトリテラル中の{ get count() { … } })を評価しなくなった。従来は生のオブジェクトをthisとして評価していたため、パスgetterはnullとして直列化され、$getAllを呼ぶgetterは例外を投げてページ全体のSSRを倒していた。派生値は同じ定義からクライアントで再計算される。スナップショットからgetterの値を読んでいた利用側は、その導出元のデータを読むように変えること。
v2.5.0: hydration applies the bindings inside server-rendered blocks — the rows of a for and the contents of an if — once, as it already did for the bindings outside them. A getter records its dependencies only when it is evaluated, so before this a row getter (textContent: items.*.double) kept the server-rendered text forever while an aggregate outside the rows updated normally, and nothing said so. The rendered text does not change — the values come from the same state — only the dependency edges appear. Structural and event bindings, bindings on comment nodes and index bindings ($1, $2, which depend on no state) are deliberately left as the server rendered them. Still gaps: the inner rows of a nested for (left unapplied rather than reporting a failure on every hydration), mustache text inside rows, a for inside an if, and bind-component children inside rows.
v2.5.0: ハイドレーションはサーバーが描いたブロックの中のバインディング — forの行とifの中身 — を一度だけ適用するようになった。外側のバインディングにはすでにそうしていた。getterが依存を記録するのは評価されたときだけなので、それまでは行getter(textContent: items.*.double)がサーバーの描いた文字列を持ち続け、行の外の集計値はふつうに更新され、そのことを告げるものは何も無かった。描画される文字は変わらない(値は同じstateから来る)。現れるのは依存の辺だけだ。構造バインディングとイベントバインディング、コメントノード上のバインディング、インデックスバインディング($1・$2。stateに依存しない)は、意図してサーバーが描いたままにされる。残る穴: ネストしたforの内側の行(ハイドレーションのたびに失敗を報告するのではなく未適用のまま残す)、行の中のmustacheテキスト、ifの中のfor、行の中のbind-componentの子。
3.0: @wcstack/server renders with the same runtime as the browser, so server output follows the empty-value rules — an undefined / null attribute is not emitted and an undefined text is empty. The API is unchanged; snapshot tests may need new expectations.
3.0: @wcstack/serverはブラウザと同じランタイムで描画するので、サーバーの出力も空の値の規則に従う — undefined / nullの属性は出力されず、undefinedのテキストは空になる。APIは変わらない。スナップショットテストは期待値の更新が要るかもしれない。
v3.3.0: renderToString has a deadline, RenderOptions.timeoutMs (default 30,000 ms; 0, NaN or anything above 231−1, Infinity included, means no limit), shared with the cleanup so the worst case is the limit plus one second. Renders are serialized through a mutex, and a page that never became ready — a volume loading from an inline <script type="module"> was one — or an invalid url used to keep it, hanging every later render on the process. A snapshot now carries only this document’s templates and hydration props (a later page could carry an earlier request’s for rows), and the ledgers are reset after each render. On the client, {{ }} inside a for / if survives hydration instead of going permanently empty, a deferred ...: spread is wired again, data-wcs-ssr-id is removed once hydrated, and a falsy 0 / false is no longer dropped from server textContent.
v3.3.0: renderToStringに期限RenderOptions.timeoutMsが付いた(既定30,000 ms。0・NaN・231−1を超える値は、Infinityを含めて制限なし)。期限は後始末と共有されるので、最悪でも期限プラス1秒だ。描画はミューテックスで直列化されており、準備の整わないページ — インラインの<script type="module">から読み込むボリュームがそうだった — や不正なurlがそれを握ったままにすると、そのプロセスの以降の描画がすべて止まった。スナップショットはいまこの文書のテンプレートとハイドレーション用プロパティだけを運び(後のページが前のリクエストのforの行を運び得た)、台帳は描画ごとにリセットされる。クライアント側では、for / ifの中の{{ }}がハイドレーション後に永久に空になることはもう無く、遅延した...:のspreadが再び配線され、ハイドレーション後にdata-wcs-ssr-idが取り除かれ、サーバーのtextContentから偽の0 / falseが落ちることも無くなった。
v3.4.0: most hydration fixes came from hydrating real server output instead of hand-written fixtures. A nested for did not just leave its inner rows unapplied, as the v2.5.0 note above says: hydration threw ListIndex not found, the bindings-ready promise rejected and no binding on the page followed a write again, and a for inside an if drew pushed rows next to the server’s. Hydrating those shapes stays a known limitation, but when a server-rendered for row sits inside another for / if / elseif / else block, the client now discards the server DOM, renders the page itself and says so once with console.warn (a nested for whose inner lists were empty on the server still hydrates); the full-render fallback restores nested templates, and rows and branches created after hydration keep the templates nested in them. The <wcs-ssr> snapshot keeps the filters of for: / if: / elseif:, so if: x|not with an else: no longer hydrates inverted (a snapshot from an earlier server is read as before). An else: pairs with the last if: / elseif: at its own level, so an if: nested in the branch, or one in a Light DOM bind-component child, no longer takes it, and switching a chain inside a rendered branch or row no longer leaves two branches showing. Rows added to a non-empty list while the server rendered keep their boundary comments in order, so the list can be rewritten after hydration; a row whose template has an if: or {{ }} at its top level rewrites like a client-rendered one; {{ $1 }} shows the index at once; an if: inside a hydrated row can be toggled; a bind-component child in a hydrated row or branch follows its parent; a spread onto a not-yet-defined element in a hydrated row expands once, in its row; and two for:s over one array — the same path twice, or a path and a getter that returns it — no longer duplicate rows. On the server, blocks removed or hidden during the render no longer leave their boundary comments behind.
v3.4.0: ハイドレーションの修正の大半は、手書きのフィクスチャではなく本物のサーバー出力をハイドレートして見つかった。入れ子のforは、上のv2.5.0の注記が言うように内側の行を未適用のまま残すだけではなかった: ハイドレーションはListIndex not foundをthrowし、bindings-readyのpromiseはrejectし、ページ上のどのバインディングも二度と書き込みに追従しなかった。ifの中のforは、追加した行をサーバーの行の隣に描いた。こうした形のハイドレーションは既知の制限のままだが、サーバーが描いたforの行が別のfor / if / elseif / elseのブロックの中にあるとき、クライアントはいまサーバーのDOMを捨てて自分でページを描き、console.warnで一度そう告げる(内側のリストがサーバーで空だった入れ子のforは今もハイドレートされる)。全描画へのフォールバックは入れ子のテンプレートを復元し、ハイドレーション後に作られた行と枝は、その中に入れ子になったテンプレートを保つ。<wcs-ssr>のスナップショットはfor: / if: / elseif:のフィルタを保つので、else:を伴うif: x|notが反転してハイドレートされることはもう無い(以前のサーバーのスナップショットは以前どおりに読まれる)。else:は自分と同じ階層の最後のif: / elseif:と組になるので、枝の中に入れ子になったif:や、Light DOMのbind-componentの子の中のif:がそれを奪うことはもう無く、描画済みの枝や行の中で連鎖を切り替えても2つの枝が並んで残ることも無い。サーバーの描画中に空でないリストへ追加された行は境界のコメントを順序どおりに保つので、ハイドレーション後もリストを書き換えられる。テンプレートの最上位にif:や{{ }}を持つ行も、クライアントで描いた行と同じように書き換わる。{{ $1 }}はすぐにインデックスを示し、ハイドレートした行の中のif:は切り替えられ、ハイドレートした行や枝の中のbind-componentの子は親に追従し、ハイドレートした行の中のまだ定義されていない要素へのspreadは、その行の中で一度だけ展開され、1つの配列に対する2つのfor: — 同じパスを2回、あるいはパスとそれを返すgetter — が行を重複させることももう無い。サーバー側では、描画中に取り除かれたり隠されたりしたブロックが境界のコメントを残すことはもう無い。
v3.5.3 → v3.5.4: server-rendered {{ }} text keeps its output filters after hydration. The comments the server writes around a text binding (<!--@@wcs-text-start:…--> / <!--@@wcs-text-end:…-->) carried only the binding’s path, and hydration rebuilds the binding from them, so {{ price|toFixed(2) }} came back as {{ price }}: the page showed 3.14159 instead of the server’s 3.14, and every later write stayed unfiltered, with nothing reported. The same happened to <!--@@: … -->, to filter chains, and to the {{ }} in server-rendered for: rows and if: branches; the version-mismatch full render lost the filters of page-level text. The markers now carry the whole expression (price|toFixed(2); items.*.price|toFixed(2) in a row), which every 3.x client already reads as it is, so upgrading the server’s @wcstack/state is enough: clients need no update, and output from an older server hydrates as before. An expression a comment cannot hold (one containing --) is still written as its path alone, and its value shows unfiltered. A {{ }} in a Light DOM bind-component child that reads an aliased key (state.p: price, {{ p|toFixed(2) }}) carried the host’s path and hydrated empty for good, in a row or a branch too; it now carries the child’s own expression. 3.5.4 does the same for that fallback: in such a child, 3.5.3 wrote the path translated to the host’s vocabulary (v for the child’s x under state.x: v, #m1.other for a private key), so the text hydrated empty again; it now writes the path the child wrote (x, other). Again, upgrading the server is enough.
v3.5.3 → v3.5.4: サーバーが描いた{{ }}のテキストは、ハイドレーション後も出力フィルタを保つ。サーバーがテキストバインディングの周りに書くコメント(<!--@@wcs-text-start:…--> / <!--@@wcs-text-end:…-->)はバインディングのパスしか運ばず、ハイドレーションはそこからバインディングを組み直すので、{{ price|toFixed(2) }}は{{ price }}として戻ってきた: ページはサーバーの3.14の代わりに3.14159を示し、以降の書き込みもすべてフィルタを通らず、何も報告されなかった。<!--@@: … -->、フィルタの連鎖、サーバーが描いたfor:の行とif:の枝の中の{{ }}も同じで、バージョン不一致での全描画はページ直下のテキストのフィルタを失っていた。目印はいま式全体を運び(price|toFixed(2)、行の中ならitems.*.price|toFixed(2))、3.xのクライアントはどれもそれをそのまま読めるので、サーバーの@wcstack/stateを上げるだけでよい: クライアントの更新は要らず、古いサーバーの出力は以前どおりにハイドレートされる。コメントに入れられない式(--を含むもの)は今もパスだけで書かれ、その値はフィルタを通らずに示される。別名のキーを読むLight DOMのbind-componentの子の中の{{ }}(state.p: price、{{ p|toFixed(2) }})はホストのパスを運び、行や枝の中でも永久に空のままハイドレートされていた。いまは子自身の式を運ぶ。3.5.4はその代替にも同じことをする: そうした子の中で、3.5.3はホストの語彙に訳したパス(state.x: vの下の子のxならv、私有キーなら#m1.other)を書いたので、テキストはまた空のままハイドレートされた。いまは子が書いたパス(x、other)を書く。これもサーバーを上げるだけでよい。
v4.0.0: server and client move together. 4.0 writes a new output and reads only its own major.minor. The server runs $connectedCallback (including fetch()), applies the bindings and marks rows, branches and text bindings with comments — <!--wcs-p:wcs-t0--> where a page-level template was (the template itself is in <wcs-ssr>), <!--wcs-[--> … <!--wcs-]--> around a list’s rows, <!--wcs-t:EXPR-->value<!--wcs-/t--> for a text binding with its whole expression. What a binding rendered, innerHTML included, is in the HTML as it is: 3.x’s data-wcs-ssr-id attributes and data-wcs-ssr-props table are gone. Form values go into the markup where they differ from it (an input’s value / checked, selected on the options a select has selected, a textarea’s text; a password’s value is left out). The client loads its state from its own source, takes the snapshot’s data over it, skips $connectedCallback, and adopts the server’s rows and branches where they are, nested ones included — the limitation of the v3.4.0 note above, which sent a nested for to a full client render, is gone — and a custom element in them is connected once. outerHTML: / outerText: are applied on the client, and the snapshot is still JSON (Date, Set, Map do not survive it).
v4.0.0: サーバーとクライアントは一緒に動かす。 4.0は新しい出力を書き、自分と同じmajor.minorのものだけを読む。サーバーは$connectedCallback(fetch()も含む)を走らせ、バインディングを適用し、行・枝・テキストバインディングにコメントで印を付ける — ページ直下のテンプレートがあった場所に<!--wcs-p:wcs-t0-->(テンプレートそのものは<wcs-ssr>の中にある)、リストの行の周りに<!--wcs-[--> … <!--wcs-]-->、テキストバインディングには式全体を持つ<!--wcs-t:EXPR-->value<!--wcs-/t-->。バインディングが描いたものは、innerHTMLも含めてそのままHTMLにある: 3.xのdata-wcs-ssr-id属性とdata-wcs-ssr-propsの表は無くなった。フォームの値はマークアップと違う所でマークアップに書かれる(入力欄のvalue / checked、selectが選んでいるoptionのselected、textareaの文字。パスワードの値は除く)。クライアントは自分のソースからstateを読み込んでスナップショットのデータを重ね、$connectedCallbackを飛ばし、サーバーの行と枝を、入れ子のものも含めて、その場で引き取る — 入れ子のforをクライアントでの全描画に回していた上のv3.4.0の注記の制限は無くなった — そしてその中のカスタム要素は一度だけ接続される。outerHTML: / outerText:はクライアントで適用され、スナップショットは今もJSONだ(Date・Set・Mapは残らない)。
SSR output is not compatible across 3.x and 4.0. A 4.0 client discards the snapshot of a 3.x server with one console.warn — <wcs-ssr version="3.5.4"> does not match 4.0.0: its snapshot is discarded, and the page renders on the client from its own state. — and renders the page on the client as if there were no server: the page works, but what the server did is done again, and data that $connectedCallback fetches is fetched again. With the output of a server up to 3.5.2, a text binding outside templates also loses its filters there (the warning says so); 3.5.3 and later keep the whole expression. A 4.0 server with a 3.x client is not supported. Deploy @wcstack/server 4.0 together with the 4.0 client, purge HTML cached from 3.x, and keep the comments in the output — a minifier’s removeComments breaks hydration, and their format is not an API to post-process.
SSRの出力は3.xと4.0の間で互換が無い。 4.0のクライアントは3.xのサーバーのスナップショットをconsole.warn1つとともに捨て — <wcs-ssr version="3.5.4"> does not match 4.0.0: its snapshot is discarded, and the page renders on the client from its own state. — サーバーが無かったかのようにクライアントでページを描く: ページは動くが、サーバーがしたことはもう一度行われ、$connectedCallbackが取得するデータももう一度取得される。3.5.2までのサーバーの出力では、テンプレートの外のテキストバインディングがそこでフィルタも失う(警告がそう告げる)。3.5.3以降は式全体を保つ。4.0のサーバーと3.xのクライアントの組み合わせはサポートされない。@wcstack/server 4.0を4.0のクライアントと一緒にデプロイし、3.xでキャッシュしたHTMLを捨て、出力のコメントは残すこと — minifierのremoveCommentsはハイドレーションを壊し、その形式は後処理してよいAPIではない。
All bindings work inside <svg> — use attr.NAME for SVG attributes.
<svg width="200" height="100">
<template data-wcs="for: points">
<circle data-wcs="attr.cx: .x; attr.cy: .y; attr.fill: .color" r="5" />
</template>
</svg>
defineState()Zero-cost type safety, including dot-path resolution. Utility types: WcsPaths<T>, WcsPathValue<T, P>. The wcstack-intellisense VS Code extension brings completion / diagnostics / hover to inline scripts in HTML.
ドットパス解決込みのゼロコスト型安全。VS Code拡張がHTML内インラインスクリプトに補完・診断を提供。
import { defineState } from "@wcstack/state/define"; // 69 B, no runtime
export default defineState({
count: 0,
users: [] as { name: string; age: number }[],
increment() {
this.count++; // ✓ number
this["users.*.name"]; // ✓ string (dot-path resolution)
this.$getAll("users.*.age", []); // ✓ API method
},
get "users.*.ageCategory"() {
return this["users.*.age"] < 25 ? "Young" : "Adult";
}
});
v2.6.0: importing only defineState (or only the types) no longer keeps the runtime. Three registrations used to run when the module was evaluated — the $watch and $stream runtimes and volume grafting — and a module-level side effect is something no bundler may drop. They are now installed by bootstrapState() and on each feature’s first use, so a bundled module that imports only defineState tree-shakes to about 0.3 KB gzip (it was 26.7 KB). No behaviour changes, and CI now fails a change that pushes the helper-only entry past 1 KB.
v2.6.0: defineStateだけ(あるいは型だけ)のimportがランタイムを抱え込まなくなった。モジュールの評価時点で3つの登録 — $watchと$streamのランタイム、ボリュームの接ぎ木 — が走っており、モジュールレベルの副作用はバンドラが捨ててよいものではなかった。これらはいまbootstrapState()と各機能の初回利用で組み込まれるので、defineStateだけをimportするモジュールはバンドル時のtree-shakeで約0.3 KB gzipになる(以前は26.7 KB)。挙動は変わらない。ヘルパーだけのエントリが1 KBを超える変更は、いまはCIが落とす。
3.0: @wcstack/state/define is the entry for exactly that — defineState and the types, with no runtime at all (see Split entries).
3.0: まさにそのためのエントリが@wcstack/state/defineだ — defineStateと型だけで、ランタイムは一切持たない(分割エントリ)。
v4.0.0: import defineState from @wcstack/state/define (69 B). A bundler cannot drop the 4.0 engine’s modules from the @wcstack/state or /core entry, so a state file that imports only defineState from there keeps the whole engine, about 21 KB gzip (3.x tree-shook it down to the helper). That costs nothing in a bundle that runs the engine anyway; it matters where the state file is bundled without it — a page that loads the runtime from a CDN, a build that only type-checks or tests the state. The types lose $trackDependency / $untrackDependency, and @wcstack/state also exports the type IStateElement, without the removed listPaths / getterPaths / setterPaths / nextVersion().
v4.0.0: defineStateは@wcstack/state/define(69 B)からimportすること。バンドラは@wcstack/stateや/coreのエントリから4.0のエンジンのモジュールを落とせないので、そこからdefineStateだけをimportするstateのファイルはエンジン全体、約21 KB gzipを抱える(3.xはヘルパーだけにまでtree-shakeできた)。どのみちエンジンを動かすバンドルでは何も失わないが、stateのファイルをエンジン無しでバンドルする所 — ランタイムをCDNから読み込むページ、stateの型検査やテストだけをするビルド — では効いてくる。型からは$trackDependency / $untrackDependencyが無くなり、@wcstack/stateは型IStateElementもexportする。取り除かれたlistPaths / getterPaths / setterPaths / nextVersion()はそこに無い。
bootstrapState()import { bootstrapState } from "@wcstack/state";
bootstrapState({
bindAttributeName: "data-wcs",
tagNames: { state: "wcs-state" },
locale: "en",
enableContractAnalyzer: false // opt-in
});
v3.5.0: 4.0 removes debug, commentTextPrefix and enablePropagationContext, and moves enableMustache, sameValueGuard and enableDirectionalInitialSync (each true by default) into the $behavior of each root state that needs them. 3.5 warns when a page passes any of the six, and about an unknown key or a value of another type (null, an array, a tag name that is not a string, a tagNames key other than state / ssr) — 4.0 throws on each. Delete the three that go; before deleting commentTextPrefix, write comment bindings with the default keyword (<!--@@: path--> or <!--@@wcs-text: path-->). Keep the three that move in bootstrapState() until you upgrade: 3.x does not read $behavior, so their warnings cannot be cleared on 3.5 (see Migrating from 3.x).
v3.5.0: 4.0はdebug・commentTextPrefix・enablePropagationContextを取り除き、enableMustache・sameValueGuard・enableDirectionalInitialSync(どれも既定はtrue)を、それが要る各ルートのstateの$behaviorへ移す。3.5は、ページがこの6つのどれかを渡したとき、そして未知のキーや型の違う値(null、配列、文字列でないタグ名、state / ssr以外のtagNamesのキー)を渡したときに警告する — 4.0ではどれもthrowする。なくなる3つは消すこと。commentTextPrefixを消す前に、コメントバインディングを既定のキーワードで書き直す(<!--@@: path-->または<!--@@wcs-text: path-->)。移る3つはアップグレードまでbootstrapState()に残しておく: 3.xは$behaviorを読まないので、それらの警告は3.5では消せない(3.xからの移行を参照)。
v4.0.0: configuration is split by what it decides. bootstrapState({ … }) decides, once and page-wide, how the page spells the markup — bindAttributeName, tagNames.state / tagNames.ssr, the anchor-comment prefixes commentForPrefix / commentIfPrefix / commentElseIfPrefix / commentElsePrefix — plus locale and enableContractAnalyzer. How a state tree behaves is declared by its state, in $behavior, and the features it needs in $features (see Split entries). bootstrapState() throws on an option it does not have — one 4.0 moved or removed included — on a value whose type differs from the default (null, or an array where an object is expected), on a name under tagNames other than state / ssr, and on a tag name that is not a string (#44): [@wcstack/state] bootstrapState: "enableMustache" is not one of its options, or not of the option's type. 4.0 moved it to the state's $behavior. It checks every option before applying any, so nothing is applied when it throws, and an undefined value is skipped. Every other package’s bootstrapXxx() applies the same rule, and the autoloader’s scanImportmap is removed. Pages that only load the /auto entries call them without options.
v4.0.0: 設定は、何を決めるかで分かれた。 bootstrapState({ … })が一度だけ、ページ全体について決めるのは、ページがマークアップをどう綴るか — bindAttributeName、tagNames.state / tagNames.ssr、アンカーのコメントの接頭辞commentForPrefix / commentIfPrefix / commentElseIfPrefix / commentElsePrefix — と、locale・enableContractAnalyzerだ。stateの木がどう振る舞うかはそのstateが$behaviorで宣言し、要る機能は$featuresで宣言する(分割エントリを参照)。bootstrapState()は、持っていないオプション — 4.0が移したものや取り除いたものも含む — 、既定値と型の違う値(nullや、オブジェクトの所の配列)、state / ssr以外のtagNamesの名前、文字列でないタグ名でthrowする(#44): [@wcstack/state] bootstrapState: "enableMustache" is not one of its options, or not of the option's type. 4.0 moved it to the state's $behavior. どのオプションも適用の前にすべて検査するので、throwしたときは何も適用されず、undefinedの値は読み飛ばされる。他のパッケージのbootstrapXxx()も同じ規則に従い、オートローダーのscanImportmapは取り除かれた。/autoのエントリだけを読み込むページは、オプション無しでそれらを呼ぶ。
// 3.x: bootstrapState({ locale: "ja-JP", enableMustache: false, sameValueGuard: false });
// 4.0: bootstrapState({ locale: "ja-JP" }), and in each root state that needs them:
export default {
$behavior: { enableMustache: false, sameValueGuard: false },
count: 0,
};
$behavior key | Default | What it decides |
|---|---|---|
enableMustache | true | {{ }} in text is a binding; false leaves the braces as text (comment bindings are bound either way) |
sameValueGuard | true | Drop a primitive write Object.is-equal to the current value before anything is enqueued; false lets equal writes through and makes $watch’s prev undefined |
enableDirectionalInitialSync | true | Direction-aware binding authority (#init= / #sync=); false opts out, and those modifiers throw |
Each key is a boolean; an unknown key, a value that is not a boolean, or a $behavior that is not an object (null and an array included) throws #44. It applies to the tree of the <wcs-state> that declares it, its volumes included: each root state declares its own — the page’s root, a root <wcs-state> in a shadow root, a mounted component, a DCC — and nothing is inherited from a host, while a volume may not declare one. A re-set (setInitialState()) may not change it (#45; a re-set state without $behavior is compared with the defaults, so repeat the declaration). It works on /auto pages, which had no way to pass these options in 3.x, and in JSON states ("$behavior": { "enableMustache": false }); under SSR the server reads the same state, so server and client agree.
各キーは真偽値で、未知のキー、真偽値でない値、オブジェクトでない$behavior(nullや配列も含む)は#44をthrowする。宣言した<wcs-state>の木に、そのボリュームも含めて効く: ルートのstateはそれぞれ自分のものを宣言する — ページのルート、shadow rootの中のルートの<wcs-state>、マウントされたコンポーネント、DCC — ホストから受け継ぐものは無く、ボリュームは宣言できない。再セット(setInitialState())はそれを変えられない(#45。$behaviorの無い再セットのstateは既定値と比べられるので、宣言を繰り返すこと)。3.xではこれらのオプションを渡す手段の無かった/autoのページでも、JSONのstate("$behavior": { "enableMustache": false })でも効き、SSRではサーバーも同じstateを読むので、サーバーとクライアントは一致する。
@wcstack/state and /auto ship everything, and they stay the recommended way in: a page that uses every feature is smaller as one file than as core plus features. 3.0: when a page deliberately leaves features out, it can compose them instead.
@wcstack/stateと/autoはすべてを載せており、今も推奨の入口だ: すべての機能を使うページなら、コアと機能に分けるより1ファイルのほうが小さい。3.0: 機能を意図して外すページは、代わりにそれらを組み立てられる。
<script type="importmap">
{
"imports": {
"@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@4.20.0/dist/split/core.js",
"@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@4.20.0/dist/split/features/temporal.js",
"@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@4.20.0/dist/split/features/scopes.js"
}
}
</script>
<script type="module">
import { bootstrapState, installFeatures } from '@wcstack/state/core';
import temporal from '@wcstack/state/features/temporal'; // $watch / $stream
import scopes from '@wcstack/state/features/scopes'; // bind-component, mount=, DCC
installFeatures([temporal, scopes]);
bootstrapState();
</script>
| Entry | What it adds |
|---|---|
@wcstack/state/core | The binding engine: data-wcs, {{ }} and comment bindings, for / if, path getters, events, $command / $on, the 10 condition filters (eq ne not lt le gt ge truthy falsy boolean; 3.x answered only not), bootstrapState, installFeatures |
…/features/temporal | $watch, $stream ($scan is removed in 4.0 — $scan) |
…/features/list-keys | $listKeys (4.0; it was in the core) |
…/features/native-commands | command.<method>: on native elements (4.0 — Native element commands) |
…/features/scopes | bind-component, mount= volumes, exported getters, DCC (data-wc-definition) |
…/features/recursion | $recursion and ** paths |
…/features/ssr | enable-ssr: server rendering and hydration |
…/features/formats | The other 37 filters: arithmetic, conversion and defaults (add, int, coalesce, …) and formatting (upper, date, round, truncate, …) |
…/features/devtools | The DevTools hook protocol source |
…/features/diagnostics | 4.0: the sentences of error messages, with the did-you-mean and the pointer to lint, and the development-time warnings (a bound or $watch path that does not resolve). Without it a message is its number and its values (#501 "uc"; docs/state-errors.md lists them), those warnings are not printed, and the 3.x names 4.0 removed are not detected |
@wcstack/state/define | defineState and the types only — no runtime at all |
A missing feature does not fail quietly: a declaration that needs one throws [wcs/feature-not-installed] $watch needs the add-on @wcstack/state/features/temporal when the state is loaded, and a filter of formats without it throws [wcs/filter-unknown], naming the add-on, when the bindings are planned. Call installFeatures([...]) before bootstrapState(): an engine created before a feature was installed does not gain it later. Installing is idempotent — 4.0 skips a feature it already installed — and every entry shares one core chunk: the core with its chunks is about 24.5 KB gzip as of 4.0 (59 KB at 3.5.4, 52 KB at 3.0), and the core alone, built for the size gate, 19.8 KB against its 20 KiB target. The file names under dist/split/chunks/ carry a content hash in 4.0, so take them from the build you load if you list chunk files yourself (preload links, integrity in an import map).
欠けた機能は黙っては失敗しない: それを要る宣言はstateの読み込み時に[wcs/feature-not-installed] $watch needs the add-on @wcstack/state/features/temporalをthrowし、formatsの無いページのformatsのフィルタは、バインディングの計画時にその機能を名指しして[wcs/filter-unknown]をthrowする。installFeatures([...])はbootstrapState()より前に呼ぶこと: 機能を組み込む前に作られたエンジンは、後からそれを得ない。組み込みは冪等で — 4.0はすでに組み込んだ機能を飛ばす — どのエントリも1つのコアチャンクを共有する: チャンク込みのコアは4.0で約24.5 KB gzip(3.5.4では59 KB、3.0では52 KB)、サイズの検査のためにビルドするコアだけなら、20 KiBの目標に対して19.8 KBだ。4.0ではdist/split/chunks/の下のファイル名が内容のハッシュを持つので、チャンクのファイルを自分で並べるなら(preloadのリンク、import mapのintegrity)、読み込むビルドから名前を取ること。
v3.3.0: that promise holds now — from 3.0 to 3.2 a $watch / $scan / $stream / $recursion declaration on a page that installed no features passed silently. A volume declaring $watch without features/temporal reports it too (it used to work by accident), a feature installed after bootstrapState() still defines its tags (<wcs-ssr> stayed undefined), and a feature entry no longer carries another feature’s code: features/scopes goes from 19.7 to 14.0 KB gzip of its own, features/devtools from 5.4 to 2.2 KB, features/temporal from 10.6 to 9.7 KB.
v3.3.0: この約束がいまは守られる — 3.0から3.2までは、機能を1つも組み込んでいないページの$watch / $scan / $stream / $recursionの宣言が黙って通っていた。features/temporal無しで$watchを宣言したボリュームもそれを報告し(たまたま動いていた)、bootstrapState()の後に組み込んだ機能もタグを定義し(<wcs-ssr>が未定義のままだった)、機能のエントリは別の機能のコードを運ばなくなった: features/scopes単体は19.7から14.0 KB gzipへ、features/devtoolsは5.4から2.2 KBへ、features/temporalは10.6から9.7 KBへ。
v3.5.0: the [wcs/v4-migration] warnings ship in the full entries only, so a page on the split entries prints none — the core calls an empty receptacle where it resolves an old name, and CI fails a change that lets the warnings into a split output. Before upgrading such a page, run it once on @wcstack/state or /auto, or rely on the lint. The core grows by 0.1 KB.
v3.5.0: [wcs/v4-migration]の警告が載るのは完全版のエントリだけなので、分割エントリのページは何も出さない — コアは旧名を解決する場所で空の受け口を呼ぶだけで、分割形の出力に警告が入り込む変更はCIが落とす。そうしたページをアップグレードする前に、一度@wcstack/stateか/autoで動かすか、lintに頼ること。コアは0.1 KB増える。
dist/split/auto.js loads the split build from one script tag, without an import map: it loads the core, and the features the page names from the same build. It is not in exports — a bundler cannot follow the URLs it builds at run time, so a bundled page keeps @wcstack/state/core and installFeatures — and the features it imports at run time are outside the integrity of its <script>; under a CSP they need the CDN host allowed (integrity: docs/sri.md §5.2; CSP: docs/csp.md §2.1).
dist/split/auto.jsは、import map無しにscriptタグ1つで分割形のビルドを読み込む: コアと、ページが名指しした機能を同じビルドから読み込む。exportsには無い — バンドラは実行時に組み立てるURLを追えないので、バンドルするページは@wcstack/state/coreとinstallFeaturesを使い続ける — そして実行時にimportする機能はその<script>のintegrityの外にあり、CSPの下ではCDNのホストを許可する必要がある(integrityはdocs/sri.md §5.2、CSPはdocs/csp.md §2.1)。
<script type="module" src="https://cdn.jsdelivr.net/npm/@wcstack/state@4.20.0/dist/split/auto.js"></script>
<wcs-state features="scopes diagnostics">
<script type="module">
export default {
$features: ["temporal", "formats"],
price: 1200,
$watch: { price(cur) { console.log(cur); } },
};
</script>
</wcs-state>
<p>{{ price|locale }}</p>
features="…" on the root <wcs-state> | $features: [...] in a state | |
|---|---|---|
| Where | The document’s root <wcs-state> — the first one without mount and bind-component | Any state that is not a volume (a root, a mounted component, a DCC) |
| When it is read | Once, before <wcs-state> is defined | When that state is loaded, before its engine is built |
| What it is for | What must be there before any <wcs-state> starts — scopes — and development aids (diagnostics, devtools) | The features that state needs (temporal for its $watch, formats for its filters) |
| Who reads it | Only the split auto entry | Every entry: the split auto entry loads the missing ones; the others check they are installed and fail the state with [wcs/feature-not-installed] otherwise |
The names are formats, diagnostics, temporal, list-keys, scopes, recursion, ssr, devtools and native-commands. Any other name in features= makes the split auto entry fail with [wcs/feature-unknown] before it defines <wcs-state>; in $features it fails that state. A $features that is not an array throws #46, and a volume may not declare one. Load it from a plain, version-pinned /npm/ path, never through esm.run.
名前はformats・diagnostics・temporal・list-keys・scopes・recursion・ssr・devtools・native-commandsだ。features=にそれ以外の名前があると、分割形の自動エントリは<wcs-state>を定義する前に[wcs/feature-unknown]で失敗し、$featuresにあればそのstateが失敗する。配列でない$featuresは#46をthrowし、ボリュームはそれを宣言できない。素の、バージョンを固定した/npm/のパスから読み込み、決してesm.runを通さないこと。features="…"は文書のルートの<wcs-state>(mountもbind-componentも持たない最初のもの)に書き、<wcs-state>が定義される前に一度だけ、分割形の自動エントリだけが読む — どの<wcs-state>が始まるより前にそろっている必要があるscopesと、開発の補助(diagnostics、devtools)のための場所だ。$features: [...]はボリューム以外のどのstateにも書け、そのstateが読み込まれるときに読まれる: 分割形の自動エントリは足りない機能を読み込み、他のエントリは組み込まれているかを検査して、無ければそのstateを[wcs/feature-not-installed]で失敗させる。
Never load the split form through esm.run. Its +esm endpoint re-bundles every entry on the server and inlines the shared core chunk into each one, so every entry would carry its own engine: a feature would install into a copy the core never sees, and the page throws [wcs/feature-not-installed] in spite of installFeatures. Use jsDelivr’s plain, version-pinned /npm/ path as above — it does not read exports, so name the file under dist/split/ — or a bundler. The same holds for the split auto entry. Integrity for this form is in docs/sri.md §5.1, and the CSP rules in docs/csp.md §2.1.
分割形をesm.runから読み込まないこと。 その+esmエンドポイントはサーバー側で各エントリを再バンドルし、共有のコアチャンクをそれぞれに埋め込むので、どのエントリも自分のエンジンを持ってしまう: 機能はコアが見ることのない複製に組み込まれ、installFeaturesを呼んでもページは[wcs/feature-not-installed]をthrowする。上のようにjsDelivrの素の、バージョン固定した/npm/パスを使う — exportsを読まないのでdist/split/の下のファイル名を書く — か、バンドラを使う。分割形の自動エントリも同じだ。この形のintegrityはdocs/sri.md §5.1に、CSPの規則はdocs/csp.md §2.1にある。
4.0 replaces the engine inside @wcstack/state. What you write against stays — <wcs-state>, data-wcs, {{ }} and comment bindings, for / if, paths and path getters, the $ APIs, bind-component, mount=, enable-ssr and the package entries — and there is no compatibility layer. Instead, 3.5 named each name and option 4.0 removes or moves with [wcs/v4-migration], and its lint warns about several forms 4.0 rejects, while still running them the 3.x way; a few changes nothing reports (below). The path: upgrade to 3.5, clear its warnings — all but those for the three options that move to $behavior — then move every @wcstack/* package to 4.0 at once, @wcstack/server together with the client. Most replacements below can be written on 3.5 — all but $behavior, the list-keys feature and the 4.0 server — so the fixed page runs the same on 3.5 and on 4.0. The full guide, with the reason for each change, is docs/migration-v4.md.
4.0は@wcstack/stateの中のエンジンを置き換える。書く側が向き合うもの — <wcs-state>、data-wcs、{{ }}とコメントバインディング、for / if、パスとパスgetter、$のAPI、bind-component、mount=、enable-ssr、パッケージのエントリ — は残り、互換層は無い。代わりに3.5は、3.xのやり方で動かしつつ、4.0が取り除く・移す名前とオプションをひとつずつ[wcs/v4-migration]で名指しし、そのlintは4.0が拒むいくつかの書き方を警告してきた。何も報告しない変更も少しある(後述)。道筋は、3.5に上げ、その警告を消し — $behaviorへ移る3つのオプションのものを除いて — それから@wcstack/*のパッケージをすべて一度に4.0へ移すこと。@wcstack/serverもクライアントと一緒に。下の書き換え先の大半は3.5で書ける — 書けないのは$behavior、list-keys機能、4.0のサーバーだけだ — ので、直したページは3.5でも4.0でも同じに動く。変更ごとの理由を添えた完全なガイドはdocs/migration-v4.mdだ。
[@wcstack/state] [wcs/filter-unknown] filter not found: uc. "uc" was renamed "upper" in 3.2 and removed in 4.0 — write "upper". Validate statically: npx @wcstack/lint <file>.
[@wcstack/state] #501 "uc"
The first line is what @wcstack/state and /auto print, the second what /core without features/diagnostics prints. The hundreds of a number name its code (#501 is filter-unknown), and a number keeps its meaning across versions; docs/state-errors.md lists every one. Install diagnostics while migrating a /core page: without it the removed 3.x names are not detected (a declaration under one does nothing, reading one gives undefined).
1行目は@wcstack/stateと/autoが出すもの、2行目はfeatures/diagnosticsの無い/coreが出すものだ。番号の百の位がそのコードを表し(#501はfilter-unknown)、番号の意味はバージョンをまたいで変わらない。一覧はdocs/state-errors.mdにある。/coreのページを移行する間はdiagnosticsを組み込むこと: それが無いと取り除かれた3.xの名前は検出されない(その名前の宣言は何もせず、読めばundefinedになる)。
| Form | 3.x | 4.0 | Write instead |
|---|---|---|---|
The filter names 3.2 renamed: inc dec fix uc lc cap rep rev pad null | Aliases (3.5 warns) | [wcs/filter-unknown] #501, naming the replacement | add sub toFixed upper lower capitalize repeat reverse padStart nullIfEmpty |
substr(start, length) | Works (3.5 warns) | [wcs/filter-unknown] | slice(start, start + length) — an end index, not a length; check a negative start by hand |
$trackDependency / $untrackDependency | Aliases | Throws when read [wcs/name-alias] #1701 | $dependOn / $untracked |
$updatedCallback / $streams | Aliases | Throws when the state loads [wcs/declaration-alias] #1601 | $renderedCallback / $stream |
$scan | Works (3.5 warns) | Throws when the state loads (#1) | A plain key written from $watch / $on ($scan) |
bootstrapState({ debug }), commentTextPrefix, enablePropagationContext | Work | Throw #44 | Remove them. Comment bindings stay, as <!--@@: path--> and <!--@@wcs-text: path--> |
bootstrapState({ enableMustache }), sameValueGuard, enableDirectionalInitialSync | Page-wide options | Throw #44 | $behavior in each root state that needs them — a component does not inherit its host’s, and a volume may not declare one (Configuration) |
An unknown bootstrapXxx() option, a value of another type, an unknown tagNames key — in any package | Ignored or applied unchecked (3.5 warns) | Throws, and nothing is applied | Remove or fix it; the autoloader’s scanImportmap is removed. Pages on /auto are not affected |
An on*: handler of a delegated event that reads event.currentTarget | The element | The root | event.target.closest(…), the loop index, or on*#direct: (Events) |
#stop inside an element your code listens on; stopPropagation() on an ancestor; an element moved under another root | The element-attached behaviour | The delegated behaviour | Review them; #direct where the 3.x behaviour is needed |
Swapping rows by writing elements ($resolve("items.*", [0], b)) | Rows move with their values | The value at that position is replaced; rows stay | Assign a new array (Loop) |
for: items|take(2) | Rows of the filtered array | [wcs/binding-syntax] #121 | A getter that returns the filtered list |
outerHTML: / outerText: inside for: / if: | Applied once | [wcs/template-syntax] #203 | innerHTML: on a wrapper element |
A * of another list inside a row ({{ b.*.y }} in for: a) | Each binding fails | [wcs/wildcard-rank] #1403 at initialization | Read the other list’s row in a getter with $resolve(path, indexes) |
A CSS or test selector on [data-wcs] for row or branch elements | Matches | No match: clones lose data-wcs | A class or a data-* attribute of your own |
A volume that declares $watch, $listKeys, $renderedCallback, $behavior or $features | Runs relative to the mount path (3.5 warns) | Not grafted (console.error) | Move them to the root state, with full paths (mount=) |
A volume with a data-wcs — an injection (state.taxRate: settings.taxRate) or anything else | Works (3.1) | Not grafted (console.error) | Read the root path in a root getter; wrap the volume to bind something to it |
A custom element that relied on state writing its input’s attribute | Property and attribute written | Property only | Reflect in the element’s setter (wc-bindable) |
| SSR output of a 3.x server | Hydrated | Discarded with a warning; the page renders on the client | Deploy @wcstack/server 4.0 with the client; purge cached HTML (SSR) |
import { defineState } from "@wcstack/state" in a bundle | Tree-shakes to the helper | Keeps the engine (about 21 KB gzip) | import { defineState } from "@wcstack/state/define" |
$listKeys on a /core page | In the core | [wcs/feature-not-installed] | Install @wcstack/state/features/list-keys |
IStateElement.listPaths / getterPaths / setterPaths / nextVersion() | Public | Removed | No replacement |
What nothing reports. The lint does not see bootstrapXxx() options, which are outside <wcs-state> (the 3.5 warnings do). Neither the 3.5 warnings nor the lint see #stop and stopPropagation() around delegated handlers, moved elements, element writes used to reorder rows, [data-wcs] selectors, post-processing of SSR output, custom elements that relied on state writing their input attributes, and uses of the removed IStateElement members. Read the guide’s list of changes nothing reports as well: when $watch handlers fire (Watch), the children of content-binding elements, <noscript> / <iframe> and comments in <textarea> / <title> (Binding), reads past the end of a list, and the extra end-marker comment the router outlet holds since 3.5.
何も報告しないもの。 lintは<wcs-state>の外にあるbootstrapXxx()のオプションを見ない(3.5の警告は見る)。3.5の警告もlintも、委譲されたハンドラの周りの#stopとstopPropagation()、動かした要素、行を並べ替えるための要素の書き込み、[data-wcs]のセレクタ、SSRの出力の後処理、入力の属性をstateが書くことに頼っていたカスタム要素、取り除かれたIStateElementのメンバーの利用は見えない。ガイドの「何も報告しない変更」の一覧も読むこと: $watchハンドラがいつ発火するか(Watch)、内容を設定する要素の子、<noscript> / <iframe>、<textarea> / <title>の中のコメント(Binding)、リストの末尾を越えた読み、そして3.5からルーターのoutletが持つ終端の目印のコメントだ。
The 4.0 lint. npx @wcstack/lint@4 <files> and the VS Code extension 2.0 carry the 4.0 rules; keep @wcstack/lint 3.5, @wcstack/typescript 3.5 and the extension 1.21.x for 3.x projects, since the 4.0 rules report forms 3.x still accepts. The 4.0 lint has no wcs/v4-migration: what 3.5 pointed out as info is an error or a warning of its own code there. wcs/scan-declaration-invalid, wcs/name-alias, wcs/declaration-alias, wcs/behavior-invalid, wcs/feature-unknown, wcs/index-param-range, wcs/second-root, wcs/bind-component-source, wcs/template-syntax (outerHTML: in a template) and wcs/binding-syntax (filters on for:, a __proto__ segment) are errors; wcs/volume-declaration and wcs/features-invalid are errors or warnings by case; wcs/filter-unknown (the old names and substr, with the replacement), wcs/delegated-current-target and wcs/wildcard-rank are warnings — and two of those throw at run time, so --strict is worth using. With --strict, expect false warnings on the paths of a volume loaded with src=, which the lint cannot read.
4.0のlint。 npx @wcstack/lint@4 <files>とVS Code拡張2.0が4.0の規則を持つ。3.xのプロジェクトには@wcstack/lint 3.5、@wcstack/typescript 3.5、拡張1.21.xを使い続けること。4.0の規則は3.xがまだ受け付ける書き方を報告するからだ。4.0のlintにはwcs/v4-migrationは無い: 3.5がinfoで指摘したものは、そこではそれぞれ自分のコードのerrorかwarningになる。wcs/scan-declaration-invalid・wcs/name-alias・wcs/declaration-alias・wcs/behavior-invalid・wcs/feature-unknown・wcs/index-param-range・wcs/second-root・wcs/bind-component-source・wcs/template-syntax(テンプレートの中のouterHTML:)・wcs/binding-syntax(for:のフィルタ、__proto__の区間)はerror、wcs/volume-declarationとwcs/features-invalidは場合によりerrorかwarning、wcs/filter-unknown(旧名とsubstr。置き換え先を添える)・wcs/delegated-current-target・wcs/wildcard-rankはwarningだ — そのうち2つは実行時にthrowするので、--strictを使う価値がある。--strictでは、lintが読めないsrc=で読み込んだボリュームのパスに誤った警告が出ることを見込んでおくこと。
Pin the major version. https://esm.run/@wcstack/state/auto follows the latest release, so it loads 4.0 since 4.0.0 was published. A page not yet migrated pins @3, which stays on 3.5, and so does every package it loads from a CDN, wcstack/auto included; with npm, ^3.5.0 stays on 3.x. A migrated page can pin @4.
メジャーバージョンを固定する。 https://esm.run/@wcstack/state/autoは最新のリリースを追うので、4.0.0の公開以来4.0を読み込む。まだ移行していないページは@3に固定し(3.5にとどまる)、CDNから読み込むすべてのパッケージでも同じようにする。wcstack/autoも含めて。npmなら^3.5.0は3.xにとどまる。移行したページは@4に固定できる。
<!-- not migrated yet: stays on 3.5 -->
<script type="module" src="https://esm.run/@wcstack/state@3/auto"></script>
<!-- migrated -->
<script type="module" src="https://esm.run/@wcstack/state@4/auto"></script>
Known limitation. One object at two positions of one list, or in two arrays no getter relates: a write below one of the rows does not reach the other row’s bindings and row getters; plain reads, root getters and $getAll see the new value. Call $postUpdate on the list after such a write, or replace the changed item instead of writing below it. It is to be fixed in 4.0.x. In one nested shape — two outer rows holding different arrays that share an object (#401) — 3.4 and 3.5 redrew the other outer row’s leaf bindings and 4.0 does not, so a page that relied on it needs the $postUpdate.
既知の制限。 1つのリストの2つの位置にある1つのオブジェクト、あるいはgetterが関係づけていない2つの配列にあるオブジェクト: 片方の行の下への書き込みは、もう片方の行のバインディングと行getterに届かない。素の読み・ルートのgetter・$getAllは新しい値を見る。そうした書き込みの後にそのリストへ$postUpdateを呼ぶか、下へ書く代わりに変えた要素を置き換えること。4.0.xで直される予定だ。入れ子の形の1つ — オブジェクトを共有する別々の配列を持つ2つの外側の行(#401) — では、3.4と3.5はもう一方の外側の行の葉のバインディングを描き直し、4.0はそうしないので、それに頼っていたページには$postUpdateが要る。
Before upgrading: 3.5. 3.5 printed each [wcs/v4-migration] warning once per message per page, from the full entries only (never from the split /core, so check a split page once on @wcstack/state or /auto). Where each fired: an old declaration key, $scan, $behavior and $features when the state object is loaded; an old filter name and substr when a binding that uses it is set up; a bootstrapState option when bootstrapState() is called; $trackDependency / $untrackDependency when they are read. A warning fires only on the path that reaches the old form, so a clean console proves nothing about the paths the page did not run. The 3.5 lint adds wcs/v4-migration (info) for $scan, substr (with the exact slice call) and delegated handlers that read currentTarget, and warnings for outerHTML: in templates, another list’s *, $0 / $129, this.$0 and a second root — none of them an error, and --errors-only hides info, so read the full output.
上げる前に: 3.5。 3.5は[wcs/v4-migration]の警告を、完全版のエントリからだけ、ページごと・メッセージごとに一度出した(分割形の/coreからは出さないので、分割形のページは一度@wcstack/stateか/autoで確かめること)。どこで出たか: 旧い宣言キー・$scan・$behavior・$featuresはstateオブジェクトが読み込まれたとき、旧いフィルタ名とsubstrはそれを使うバインディングが組み立てられたとき、bootstrapStateのオプションはbootstrapState()が呼ばれたとき、$trackDependency / $untrackDependencyは読まれたとき。警告は旧い書き方に届いた経路でしか出ないので、静かなコンソールは何も証明しない — ページが通らなかった経路については。3.5のlintは、$scan、substr(正確なsliceの呼び出しを添えて)、currentTargetを読む委譲されたハンドラにwcs/v4-migration(info)を、テンプレートの中のouterHTML:、別のリストの*、$0 / $129、this.$0、2つ目のルートにwarningを加える — どれもerrorではなく、--errors-onlyはinfoを隠すので、出力は全部読むこと。
3.0 reads a binding as written, or refuses it by name, and it ships with no compatibility layer. Instead, 2.6.x announced every form that changes here as wcs/v3-migration, running it the 2.x way and naming it once per form and site. The shortest path: upgrade to 2.6.1, clear those warnings, then move to 3.0. Every replacement below can be written on 2.x, so the fixed page runs the same on 2.6.1 and on 3.0. Pin every @wcstack/* package to the same version. The full guide is docs/migration-v3.md.
3.0はバインディングを書かれたとおりに読むか名指しで拒み、互換層を持たずに出た。代わりに2.6.xは、ここで変わる書き方をすべてwcs/v3-migrationとして予告してきた — 2.xのやり方で動かしつつ、書き方とサイトごとに1回名指しする。最短の道は2.6.1に上げ、その警告を消し、それから3.0へ移ること。下の書き換え先はどれも2.xで書けるので、直したページは2.6.1でも3.0でも同じに動く。@wcstack/*のパッケージはすべて同じバージョンに固定する。完全なガイドはdocs/migration-v3.md。
[@wcstack/state] [wcs/v3-migration] "value#ro#wo": 3.0 rejects a second "#". Write "value#ro,wo".
See "Preparing for 3.0" in the @wcstack/state README.
| Form | 2.x | 3.0 | Write instead |
|---|---|---|---|
A second # (value#ro#wo:) | Keeps the first modifier list | [wcs/binding-syntax] | value#ro,wo: |
A value after else: | Dropped | [wcs/binding-syntax] | else: |
Modifiers or left-side filters on for / if / elseif / else / ... | A plain property binding | [wcs/binding-syntax] | The bare keyword |
| An unterminated quote in filter arguments | Closed silently | [wcs/binding-syntax] | Close the quote |
| More or fewer filter arguments than the filter accepts | Extras ignored | [wcs/filter-arity] when the bindings are planned | Drop the extras |
An empty filter (x|, x||y) | [wcs/filter-unknown] | [wcs/binding-syntax] (only the code changes) | Remove it |
radio#ro: / checkbox#ro: | A property named radio, no effect | A radio / checkbox binding that honours the modifiers | Check it does what you meant |
; / | inside quotes (join('; ')) | Splits the binding there and breaks it | Not a separator — works as written | — |
Unquoted true / false / null in eq / ne | Compares a boolean or null value with the text | Compares with the typed value | eq('true') to keep the text comparison |
Unquoted true / false / null / numbers in defaults | The text ("null", "0") | The typed value (null, 0) | defaults('null') to keep the text |
0n through truthy / falsy / defaults | Truthy | Falsy (JavaScript’s truthiness) | — |
undefined into textContent / innerText / innerHTML | Keeps the previous text (in a reused row, the previous row’s) | Empties it | Keep the value yourself if you meant “keep what is shown” |
undefined / null into attr.* | Writes "undefined" / "null" | Removes the attribute | — |
undefined into style.* | Keeps the previous value | Clears it | — |
$resolve(path, indexes, undefined) | Reads | Writes undefined (the argument count decides) | $resolve(path, indexes) to read |
$resolve(path, indexes, value) / $setAll (the ** broadcast included) on a readonly proxy | Writes | Throws This state is readonly. | Write inside createState("writable", …) |
A component write through a #ro mount (state#ro: user) | Writes the host’s tree | [wcs/mount-readonly]; a two-way input inside does not write back | Write on the host, or drop #ro |
| A component’s own default for a key an explicit partial mount covers | The default wins (wcs/mount-own-key-shadow) | The explicit mount wins | Remove the default |
A value row changes only when that value actually arrives, so a clean console proves nothing about the paths your tests did not reach — the 2.6.x warnings had the same limit. The syntax rows are the canonical parser’s judgement, and npx @wcstack/lint <file> and the VS Code extension report them as wcs/binding-syntax at error without running the page. The 2.6-only surfaces are gone: the wcs/v3-migration warnings, the lint rule of that name, and findV3MigrationIssues / findEmbeddedV3MigrationIssues from @wcstack/state/parser.
値の行が変わるのはその値が実際に届いたときだけなので、静かなコンソールは何も証明しない — テストが通らなかった経路については。2.6.xの警告にも同じ限界があった。文法の行は正規のパーサの判定で、npx @wcstack/lint <file>とVS Code拡張はページを動かさずにwcs/binding-syntaxとしてerrorで報告する。2.6だけの面は取り除かれた: wcs/v3-migrationの警告、同名のlintルール、@wcstack/state/parserのfindV3MigrationIssues / findEmbeddedV3MigrationIssues。
For tools on @wcstack/state/parser. inFilters / outFilters are IParsedFilter now (filterName / args / literals) instead of IFilterInfo with a filterFn: the implementation resolves when the bindings are planned, so parsing alone does not fail on an unknown filter. splitBindTexts is added — the runtime’s own quote-aware ; splitter, for tools that cut expressions by position. The manifest stays at schemaVersion 2, and the DevTools hook protocol at v2.
@wcstack/state/parserを使うツールへ。 inFilters / outFiltersは、filterFnを持つIFilterInfoからIParsedFilter(filterName / args / literals)になった: 実装はバインディングの計画時に解決されるので、解析だけでは未知のフィルタで失敗しない。splitBindTextsが加わった — ランタイム自身の、引用符を意識した;の分割器で、位置で式を切り出すツールのためのものだ。マニフェストはschemaVersion 2のまま、DevToolsのフックプロトコルもv2のままだ。
On the official js-framework-benchmark, measured with 4.0.0-rc.7 (Chrome 154, all four in one session, the full auto.min.js). Lower is better; the first two rows are geometric means of each operation’s factor against the fastest of the four, as in the official results table.
| vanillajs | 4.0 | @wcstack/signals | 3.5.4 | |
|---|---|---|---|---|
| CPU (weighted geometric mean) | 1.02 | 1.07 | 1.22 | 1.44 |
| Memory (geometric mean) | 1.00 | 1.95 | 1.41 | 4.40 |
| Run memory, 1,000 rows (MB) | 2.03 | 2.97 | 3.72 | 6.35 |
4.0’s JavaScript time is about a third of 3.5.4’s (script-only geometric mean 2.03 against 6.28, vanillajs = 1). To compare on your own hardware, run the drivers in e2e/bench/ (jsfb-verify.mjs, memory-profile.mjs): they measure the standard 1,000 / 10,000-row table page in headless Chromium, both implementations back-to-back in the same session, as medians; absolute values swing by ±20 % with machine state.
公式のjs-framework-benchmarkで、4.0.0-rc.7(Chrome 154、4つを同じセッションで、完全版のauto.min.js)を計測した。小さいほど良い。上の2行は、公式の結果表と同じく、操作ごとの4つの中の最速に対する倍率の幾何平均だ。4.0のJavaScriptの時間は3.5.4の約3分の1(スクリプトだけの幾何平均で2.03対6.28、vanillajs = 1)。手元のハードウェアで比べるにはe2e/bench/のドライバ(jsfb-verify.mjs、memory-profile.mjs)を走らせる: 標準の1,000 / 10,000行の表のページをheadless Chromiumで、2つの実装を同じセッションで続けて計測し、中央値を取る。絶対値はマシンの状態で±20 %ぶれる。
v2.5.1: reading a cached getter got a little cheaper. The lookup of a path’s tree-pinned node probed each tier twice (has, then get) and kept its inner table as a WeakMap keyed by an object that lives for as long as the page does; it is a plain Map now, and each tier is one get. Measured against 2.5.0 on the release build in the same session: a getter read 3.7 ns faster, a row getter read 3.8 ns faster, and a plain path read unchanged — it never passes through that lookup (the three reads cost about 46 / 125 / 42 ns). Nothing else moved: no behaviour changed, and the release is otherwise a removal of two undocumented properties on the absolute address that DevTools hook events carry.
v2.5.1: キャッシュ済みのgetterの読みが少し安くなった。パスの木に固定されたノードの探索は、段ごとに2回(hasのあとget)調べたうえ、内側の表をページと同じだけ生きるオブジェクトをキーにしたWeakMapで持っていた — いまは素のMapで、各段はget1回だ。2.5.0に対し、同じセッションのリリースビルドで実測: getterの読みが3.7 ns、行getterの読みが3.8 ns速くなり、素のパスの読みは変わらない(そこを通らないため)。3つの読みはそれぞれおよそ46 / 125 / 42 nsである。ほかは動いていない — 挙動の変更は無く、このリリースの残りはDevToolsのフックイベントが運ぶ絶対アドレスから未公開のプロパティを2つ取り除いたことだけだ。
v2.6.0: two list operations got cheaper. Select — with a keyed selection getter, selecting one of 10,000 rows goes from 20 ms to 0.2 ms, because a write to the selection path re-evaluates two rows instead of all of them (the plain this.$1 === this.selectedIndex getter is unchanged; the gain is in switching to $eqPath / $eqIndex). Clear — clearing 10,000 rows allocated 8.1 MB and ran the young-generation collector inside the clear; the teardown now runs index loops instead of iterators and keeps one observer-skip count per parent instead of a growing per-node WeakSet, which brings it to 2.1 MB with no scavenge in that window (18–20 ms). No behaviour changes.
v2.6.0: リスト操作が2つ安くなった。選択 — 鍵付き選択のgetterなら、1万行から1行を選ぶ操作は20 msから0.2 msになる。選択パスへの書き込みが再評価するのが全行ではなく2行だからだ(素のthis.$1 === this.selectedIndexのgetterは変わらない。効くのは$eqPath / $eqIndexに書き換えたとき)。クリア — 1万行のクリアは8.1 MBを確保し、若い世代のGCがクリアの最中に走っていた。解体処理はいまイテレータではなくindexのループで回り、オブザーバーを読み飛ばす印をノードごとに育つWeakSetではなく親ごとに1つのカウントで持つので、2.1 MBでその間のscavengeも無い(18–20 ms)。挙動の変更は無い。
v2.6.1: the 0.2 ms select holds for a selection path that is written. A keyed path that is a getter, or sits under one, changes without a write, so it now falls back to an ordinary tracked read — correct where 2.6.0 re-evaluated no row at all, and costing what the plain comparison getter costs, every row per change. Point path at the state you write (Keyed selection).
v2.6.1: 0.2 msの選択が成り立つのは、選択パスに書き込むときだ。getterである(あるいはgetterの下にある)鍵付きのpathは書き込み無しに変わるので、いまは普通の追跡付きの読みに戻る — 2.6.0ではどの行も再評価されなかったのが正しく動くようになった代わりに、素の比較getterと同じく変化のたびに全行ぶんのコストがかかる。pathは書き込む先のstateに向けること(鍵付き選択)。
v3.0.0: creating, appending and clearing rows got faster, and a row holds less. A plan row now renders straight from its row object, each row keeps one record, a list shares one binding session, and the cold row path stops allocating per node. Medians against 2.5.1 on the audit bench: creating 10,000 rows (cold) −18.7 %, appending 1,000 rows −15.9 %, clearing 10,000 rows −14.7 %; creating 1,000 rows warm is unchanged, and no measure regressed. The heap per row goes from 3.6 to 3.0 KB. dist/index.esm.js, what @wcstack/state resolves to, is minified (321 KB → 79 KB gzip) — build from source with WCS_STATE_UNMINIFIED=1 for readable names in a profile. auto.min.js grows from 71.6 KB (2.6.1) to about 77 KB gzip, for the feature receptacles and the 3.0 fixes; the split form’s core alone is about 52 KB.
v3.0.0: 行の生成・追加・クリアが速くなり、1行が持つものも減った。プランの行はいま行オブジェクトから直接描画され、各行が持つ記録は1つ、リストはバインディングのセッションを1つ共有し、冷えた状態の行の経路はノードごとの確保をやめた。監査ベンチでの2.5.1比の中央値: 1万行の生成(コールド)−18.7 %、1,000行の追加−15.9 %、1万行のクリア−14.7 %。1,000行の生成(ウォーム)は変わらず、悪化した計測は無い。1行あたりのヒープは3.6から3.0 KBになった。@wcstack/stateが解決する先のdist/index.esm.jsはminifyされた(321 KB → 79 KB gzip) — プロファイルで読める名前が要るならWCS_STATE_UNMINIFIED=1でソースからビルドする。auto.min.jsは機能の受け口と3.0の修正のぶん、71.6 KB(2.6.1)から約77 KB gzipに増える。分割形のコアだけなら約52 KBだ。
v3.3.0: hydration builds its index of server-rendered nodes once instead of querying the document per prop: 800 / 1,600 / 3,200 textContent rows went from 109 / 297 / 1,207 ms to 45 / 55 / 108 ms, close to plain client rendering. The keyed-selection fix above keeps its cost: a selection change at 10,000 rows still re-evaluates about two getters, about 2 µs more than before, and a write with no keyed subscription is unchanged.
v3.3.0: ハイドレーションは、プロパティごとに文書を検索する代わりに、サーバーが描いたノードの索引を一度だけ作る: 800 / 1,600 / 3,200行のtextContentで109 / 297 / 1,207 msから45 / 55 / 108 msへ — 素のクライアント描画に近い。上の鍵付き選択の修正はコストを保つ: 1万行での選択の変更が再評価するgetterは今も約2個で、以前より約2 µs増えるだけ、鍵付きの購読が無い書き込みは変わらない。
v3.4.0: the fixes cost size. auto.min.js goes from 80.9 to 86.6 KB gzip, dist/index.esm.js from 83.4 to 89.3 KB, the split core from 54.8 to 59.2 KB, and the wcstack/auto entry bundle from 352 to 368 KB min (102 → 108 KB gzip). A binding with one numeric index costs what a getter over the list costs (Binding). Two costs that the fixes brought in were taken back before the release: an element write no longer slows down with the number of paths ever read below its row (after 10,000 dynamic keys it had gone from 0.06 to about 5 ms), and moving many rows walks only the nested templates that read the moved index (with 3,000 outer rows it had cost 1.4 to 5 times as much). Rebuilding the outer and middle rows of a three-level list again and again no longer slows down either: the 40th, 80th and 120th rebuild took 39.5 / 78.7 / 107.8 ms in 3.3.0 and take 6 to 23 ms now (happy-dom).
v3.4.0: 修正はサイズを払った。auto.min.jsは80.9から86.6 KB gzipへ、dist/index.esm.jsは83.4から89.3 KBへ、分割形のコアは54.8から59.2 KBへ、wcstack/autoのエントリバンドルは352から368 KB min(102 → 108 KB gzip)へ。数値インデックスを1つ持つバインディングは、リストに対するgetterと同じコストがかかる(Binding)。修正が持ち込んだ2つのコストはリリース前に取り戻した: 要素の書き込みが、その行の下でこれまでに読まれたパスの数に比例して遅くなることはもう無く(1万個の動的なキーの後、0.06から約5 msになっていた)、多くの行の移動は、移動したインデックスを読む入れ子のテンプレートだけをたどる(外側3,000行で1.4〜5倍かかっていた)。3段のリストの外側と中段の行を何度も作り直しても、もう遅くならない: 40回目・80回目・120回目の作り直しは3.3.0で39.5 / 78.7 / 107.8 msかかり、いまは6〜23 msだ(happy-dom)。
v3.5.0 → v3.5.4: the 4.0 warnings cost size and nothing else. auto.min.js goes from 86.6 to 87.7 KB gzip, dist/index.esm.js from 89.3 to 90.7 KB, and the wcstack/auto entry bundle from 368 to 373 KB min (108 → 109 KB gzip); the split core, which carries no warnings, goes from 59.2 to 59.3 KB. A page that writes the canonical names runs one more comparison where a filter is set up and one where $dependOn / $untracked is read, with no measurable difference. The 3.5.1 binder fixes add 30 B to dist/index.esm.js and 37 B to the split core; the 3.5.2 component fixes add 148 B to auto.min.js, 149 B to dist/index.esm.js and 26 B to the split core; and the 3.5.3 SSR text fix adds 84 B, 99 B and 1 B, plus 70 B to features/ssr, with 32 B, 33 B, 0 B and 35 B more for its 3.5.4 follow-up. As of 3.5.4, auto.min.js is 88.0 KB gzip, dist/index.esm.js 90.9 KB, and the wcstack/auto entry bundle 373.8 KB min.
v3.5.0 → v3.5.4: 4.0への警告が払うのはサイズだけだ。auto.min.jsは86.6から87.7 KB gzipへ、dist/index.esm.jsは89.3から90.7 KBへ、wcstack/autoのエントリバンドルは368から373 KB min(108 → 109 KB gzip)へ。警告を載せない分割形のコアは59.2から59.3 KBへ。正規の名前で書いたページが余分に走らせるのは、フィルタを組み立てる場所と$dependOn / $untrackedを読む場所での比較1つずつで、測れるほどの差は無い。3.5.1のバインダーの修正はdist/index.esm.jsに30 B、分割形のコアに37 Bを足す。3.5.2のコンポーネントの修正はauto.min.jsに148 B、dist/index.esm.jsに149 B、分割形のコアに26 Bを、3.5.3のSSRのテキストの修正は同じく84 B・99 B・1 Bと、features/ssrに70 Bを足し、3.5.4のその続きがさらに32 B・33 B・0 B・35 Bを足す。3.5.4の時点でauto.min.jsは88.0 KB gzip、dist/index.esm.jsは90.9 KB、wcstack/autoのエントリバンドルは373.8 KB minだ。
v4.0.0: the rewritten engine, built with esbuild and terser (build.mjs) instead of tsc + Rollup, is smaller as well as faster. Gzip, 3.5.4 → 4.0: auto.min.js 88,005 → 47,715 B (−46 %), dist/index.esm.js 90,932 → 50,653 B (−44 %), the split /core with its chunks 59,334 → 24,500 B (−59 %), and the core alone (dist/core.min.js, built for the size gate and not published) 19,846 B against its 20 KiB (20,480 B) target. The wcstack/auto entry bundle (state, router, fetch, storage and the autoloader in one file) goes from 109,793 to 69,627 B gzip (−37 %), 373.8 → 217.6 KB min. Three scaling problems are gone with it. A list whose items repeat no longer goes quadratic — a 90,000-cell board from 4.6 s to 6 ms. Paths built from data (this["usersById." + this.selectedId], $resolve("dict." + key, [])) no longer accumulate: a top-level getter depends on what its last evaluation read, and after each update the patterns only such paths made that nothing holds are dropped, so replacing the object under them no longer costs the number of keys ever used. And emptying a list, swapping an if: branch or removing a multi-node row under happy-dom — the DOM of @wcstack/testing and @wcstack/server — is no longer quadratic: a list that is all its parent holds is emptied with one replaceChildren(), 14 % faster than a Range in Chromium on 10,000 rows, where emptying 1,000 rows took 15 s in a test.
v4.0.0: tsc + Rollupの代わりにesbuildとterser(build.mjs)でビルドする書き直したエンジンは、速いうえに小さい。gzipで3.5.4 → 4.0: auto.min.jsは88,005 → 47,715 B(−46 %)、dist/index.esm.jsは90,932 → 50,653 B(−44 %)、分割形の/coreはチャンク込みで59,334 → 24,500 B(−59 %)、コアだけ(dist/core.min.js。サイズの検査のためにビルドし、公開はしない)なら20 KiB(20,480 B)の目標に対して19,846 Bだ。wcstack/autoのエントリバンドル(state・router・fetch・storage・オートローダーを1ファイルに)は109,793から69,627 B gzipへ(−37 %)、minで373.8 → 217.6 KBになった。規模に関わる3つの問題もそれとともに消えた。要素が繰り返すリストはもう二乗にならない — 9万セルの盤面で4.6 sから6 msへ。データから組み立てたパス(this["usersById." + this.selectedId]、$resolve("dict." + key, []))はもう溜まらない: トップレベルのgetterは最後の評価で読んだものに依存し、更新のたびに、そうしたパスだけが作って誰も持たなくなったパターンは捨てられるので、その下のオブジェクトの置き換えに、これまで使ったキーの数だけのコストがかかることはもう無い。そしてhappy-dom — @wcstack/testingと@wcstack/serverのDOM — の下でのリストを空にする・if:の枝を替える・複数ノードの行を取り除く操作はもう二乗にならない: 親が持つものがそのリストだけならreplaceChildren()1回で空にし、Chromiumでも1万行でRangeより14 %速い。以前はテストで1,000行を空にするのに15 sかかった。
Prefer to write the logic in TypeScript? The JS-first core @wcstack/signals drives the same wc-bindable I/O nodes with a ≈3.1 KB fine-grained runtime.
ロジックをTypeScriptで書きたいなら、同じタグ群を≈3.1 KBのきめ細かなランタイムで駆動できるJSファーストの@wcstack/signalsへ。