@wcstack/state 1.31.0 → 1.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +288 -7
- package/README.md +290 -7
- package/dist/auto.min.js +1 -1
- package/dist/auto.min.js.map +1 -1
- package/dist/index.d.ts +204 -1
- package/dist/index.esm.js +1479 -148
- package/dist/index.esm.js.map +1 -1
- package/dist/manifest.esm.js +28 -8
- package/dist/parser.esm.js +29 -8
- package/package.json +1 -1
package/README.ja.md
CHANGED
|
@@ -236,6 +236,8 @@
|
|
|
236
236
|
|
|
237
237
|
デフォルト名は `"default"`(`@` 不要)です。
|
|
238
238
|
|
|
239
|
+
> **非推奨 — v2 で廃止。** `name` 属性と `@name` セレクタは、パスの隣にあるもう 1 本の軸(rootNode ごとの登録簿で、Shadow 境界を越えない)です。v2 では**マウント**に置き換わります: `<wcs-state mount="cart">` が状態をルートツリーに接ぎ木し、バインディングは `cart.total` で読みます。1.x では何も変わりません。lint が使用箇所を `wcs/named-state-deprecated`(warning)で示し、ランタイムは `config.debug` 下でだけ warn します。移行の対応表: [docs/state-mount-design.md](../../docs/state-mount-design.md) §9。
|
|
240
|
+
|
|
239
241
|
## 状態の更新
|
|
240
242
|
|
|
241
243
|
`@wcstack/state` では、すべての状態は**パス**を持ちます — `count`、`user.name`、`items` のように。状態をリアクティブに更新するには、**パスに代入**します:
|
|
@@ -769,19 +771,20 @@ export default {
|
|
|
769
771
|
+ this["regions.*.prefectures.*.cities.*.name"];
|
|
770
772
|
},
|
|
771
773
|
|
|
772
|
-
// 県レベル —
|
|
774
|
+
// 県レベル — 市からの集約。`indexes` 省略時はループ文脈([$1, $2])が
|
|
775
|
+
// 既定になるので、この県の市だけが合計される
|
|
773
776
|
get "regions.*.prefectures.*.totalPopulation"() {
|
|
774
|
-
return this.$getAll("regions.*.prefectures.*.cities.*.population"
|
|
777
|
+
return this.$getAll("regions.*.prefectures.*.cities.*.population")
|
|
775
778
|
.reduce((a, b) => a + b, 0);
|
|
776
779
|
},
|
|
777
780
|
|
|
778
|
-
// 地方レベル —
|
|
781
|
+
// 地方レベル — 県からの集約(文脈 [$1] でこの地方に絞られる)
|
|
779
782
|
get "regions.*.totalPopulation"() {
|
|
780
|
-
return this.$getAll("regions.*.prefectures.*.totalPopulation"
|
|
783
|
+
return this.$getAll("regions.*.prefectures.*.totalPopulation")
|
|
781
784
|
.reduce((a, b) => a + b, 0);
|
|
782
785
|
},
|
|
783
786
|
|
|
784
|
-
// トップレベル —
|
|
787
|
+
// トップレベル — ループ文脈なし。[] は「マッチ全件」
|
|
785
788
|
get totalPopulation() {
|
|
786
789
|
return this.$getAll("regions.*.totalPopulation", [])
|
|
787
790
|
.reduce((a, b) => a + b, 0);
|
|
@@ -943,6 +946,7 @@ export default {
|
|
|
943
946
|
| API | 説明 |
|
|
944
947
|
|---|---|
|
|
945
948
|
| `this.$getAll(path, indexes?)` | ワイルドカードパスにマッチする全ての値を取得 |
|
|
949
|
+
| `this.$setAll(path, indexes, value, options?)` | ワイルドカードパスにマッチする全アドレスへ一括書き込み |
|
|
946
950
|
| `this.$resolve(path, indexes, value?)` | ワイルドカードパスを特定のインデックスで解決 |
|
|
947
951
|
| `this.$postUpdate(path)` | 指定パスの更新通知を手動で発行 |
|
|
948
952
|
| `this.$trackDependency(path)` | キャッシュ無効化のための依存関係を手動で登録 |
|
|
@@ -967,6 +971,66 @@ export default {
|
|
|
967
971
|
};
|
|
968
972
|
```
|
|
969
973
|
|
|
974
|
+
`indexes` はパスの `*` に対する**前方一致の接頭辞**です。不足した階層は全展開され、`[]` は常に「マッチ全件」を意味します。**省略**した場合はループ文脈の添字(`[$1, $2, ...]`)が既定になり、パスが文脈と共有するワイルドカード階層に敷かれます:
|
|
975
|
+
|
|
976
|
+
```javascript
|
|
977
|
+
export default {
|
|
978
|
+
regions: [ /* { prefectures: [ { population: … }, … ] } */ ],
|
|
979
|
+
// ループ文脈 [$1] — 省略すると現在の地方に絞られる
|
|
980
|
+
get "regions.*.total"() {
|
|
981
|
+
return this.$getAll("regions.*.prefectures.*.population").reduce((a, b) => a + b, 0);
|
|
982
|
+
},
|
|
983
|
+
// ループ文脈なし — 省略は全展開([] と同じ)
|
|
984
|
+
get grandTotal() {
|
|
985
|
+
return this.$getAll("regions.*.total").reduce((a, b) => a + b, 0);
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
文脈がパスより深い分は切り詰められます(`[$1, $2]` の文脈は `*` 1 本のパスを `[$1]` で絞ります)。一方、文脈がループ添字を持っているのにパスと**ワイルドカード階層をまったく共有しない**場合 —— たとえば `regions.*` の getter 内での `$getAll("users.*.name")` —— は、黙って全 users を読む代わりに **throw** します。文脈の添字は別のリストのものであり、流用しても無視しても書き手の意図とは食い違うためです。この形では添字を明示してください(全件なら `[]`)。
|
|
991
|
+
|
|
992
|
+
#### `$setAll` — 配列要素を作り直さずに一括更新
|
|
993
|
+
|
|
994
|
+
`$setAll` は `$getAll` の書き側の対称形で、ワイルドカードパスにマッチする全アドレスへ書き込みます。狙いは記述の短さではなく、**配列そのものを保つ**ことです。`this.users = this.users.map(...)` のように作り直すと ListIndex・行 getter のキャッシュ・差分描画がまとめて捨てられますが、`$setAll` は行ごとの in-place な書き込みに分解するのでリストの同一性が保たれます。
|
|
995
|
+
|
|
996
|
+
```javascript
|
|
997
|
+
export default {
|
|
998
|
+
users: [{ selected: false }, { selected: false }],
|
|
999
|
+
|
|
1000
|
+
toggleAll(e) {
|
|
1001
|
+
this.$setAll("users.*.selected", [], e.target.checked); // ブロードキャスト
|
|
1002
|
+
},
|
|
1003
|
+
invertAll() {
|
|
1004
|
+
this.$setAll("users.*.selected", [], cur => !cur); // mapper
|
|
1005
|
+
},
|
|
1006
|
+
rankTopThree() {
|
|
1007
|
+
// undefined を返したアドレスはスキップされる(=この行は変えない)
|
|
1008
|
+
this.$setAll("users.*.score", [], (cur, i) => i < 3 ? cur * 2 : undefined);
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
形は 3 つあり、3 番目だけは明示的に要求する必要があります。
|
|
1014
|
+
|
|
1015
|
+
| 第 3 引数 | 意味 |
|
|
1016
|
+
|---|---|
|
|
1017
|
+
| 関数 | **mapper** — マッチしたアドレスごとに `(current, ...indexes)` で呼ばれる |
|
|
1018
|
+
| それ以外 | **ブロードキャスト** — 配列も含め、同じ値が全アドレスに書かれる |
|
|
1019
|
+
| 配列 + `{ spread: true }` | **spread** — マッチ順に 1 件ずつ配る |
|
|
1020
|
+
|
|
1021
|
+
配列が既定でブロードキャストされるのは、対象プロパティ自体が配列型でありうるためです。`$setAll("users.*.tags", [], ["admin"])` は「全員に `["admin"]`」なのか「1 人目に `"admin"`」なのか判別できません。`{ spread: true }` を明示すればこの推測が消え、長さがマッチ件数と噛み合わなければ黙って誤配せずに throw します。
|
|
1022
|
+
|
|
1023
|
+
`indexes` の意味は `$getAll` と同じ**前方一致の接頭辞**(不足はその階層を全展開)ですが、**省略はできません**。書き込みには暗黙のループ文脈を与えないため、`for` テンプレートの中でも `this.$setAll("users.*.selected", [], true)` は現在行ではなく**全行**を意味します。
|
|
1024
|
+
|
|
1025
|
+
```javascript
|
|
1026
|
+
this.$setAll("matrix.*.*", [0], 0); // 0 行目だけ全列
|
|
1027
|
+
this.$setAll("users.*", [], rows, { spread: true }); // 配列を保ったまま各行を差し替え
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
`undefined` はどの形でも書き込まれず「このアドレスはスキップ」を意味します。mapper が `return` を忘れて全行を潰す事故を防ぐためで、クリアしたい場合は `null` を使います。戻り値は実際に書き込んだ件数です。
|
|
1031
|
+
|
|
1032
|
+
なお `$setAll` は依存解決を一括化する仕組みではありません。描画は 1 バッチに畳まれますが、書き込みは 1 件ずつ登録されるので、コストは置き換え対象の手書きループと同じです。得られるのはリストが保たれることであって、実行回数の削減ではありません。
|
|
1033
|
+
|
|
970
1034
|
#### `$resolve` — 明示的なインデックスでのアクセス
|
|
971
1035
|
|
|
972
1036
|
`$resolve` は特定のワイルドカードインデックスの値を読み書きします:
|
|
@@ -1192,6 +1256,47 @@ customElements.define("my-light-component", MyLightComponent);
|
|
|
1192
1256
|
- `data-wcs="state.message: user.name"` でホスト要素上の外部状態パスを内部コンポーネント状態プロパティにバインド
|
|
1193
1257
|
- 変更はコンポーネントと外部状態間で双方向に伝播
|
|
1194
1258
|
|
|
1259
|
+
### 丸ごとマウント(`state: path`)
|
|
1260
|
+
|
|
1261
|
+
プロパティ単位で配線する代わりに、ホストは自分の状態の**サブツリーを丸ごと**コンポーネントのルートとしてマウントできます。コンポーネントの中のパスは、すべてマウント先からの相対になります:
|
|
1262
|
+
|
|
1263
|
+
```html
|
|
1264
|
+
<!-- ホスト側 -->
|
|
1265
|
+
<wcs-state json='{"user":{"name":"Alice","email":"alice@example.com"},"theme":{"mode":"light"}}'></wcs-state>
|
|
1266
|
+
<user-card data-wcs="state: user"></user-card>
|
|
1267
|
+
```
|
|
1268
|
+
|
|
1269
|
+
```javascript
|
|
1270
|
+
// コンポーネント側(Shadow DOM)
|
|
1271
|
+
class UserCard extends HTMLElement {
|
|
1272
|
+
state = {
|
|
1273
|
+
// マウント先の上で計算する getter — `this.name` はツリーの `user.name`
|
|
1274
|
+
get display() { return `${this.name} <${this.email}>`; },
|
|
1275
|
+
};
|
|
1276
|
+
constructor() {
|
|
1277
|
+
super();
|
|
1278
|
+
this.attachShadow({ mode: "open" });
|
|
1279
|
+
}
|
|
1280
|
+
connectedCallback() {
|
|
1281
|
+
this.shadowRoot.innerHTML = `
|
|
1282
|
+
<wcs-state bind-component="state"></wcs-state>
|
|
1283
|
+
<span data-wcs="textContent: name"></span>
|
|
1284
|
+
<span data-wcs="textContent: display"></span>
|
|
1285
|
+
<input data-wcs="value: name">
|
|
1286
|
+
`;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
customElements.define("user-card", UserCard);
|
|
1290
|
+
```
|
|
1291
|
+
|
|
1292
|
+
- `state: user` はコンポーネントのルートをツリーのパス `user` に置きます。中の `name` は `user.name` **そのもの**です。読み・書き(`value: name`、`this.state.name = ...`)・getter・`for:` はすべてツリーに対して解決され、ホストの丸ごと差し替え(`this.user = {...}`)も部分書き込み(`this["user.name"] = ...`)もコンポーネントに届きます
|
|
1293
|
+
- 部分マウントを併用できます: `state: user; state.theme: theme` は `theme` を 2 つ目の入口としてマウントします(最長接頭辞が勝つので、中の `theme.mode` はツリーの `theme.mode` を読みます)
|
|
1294
|
+
- ループでは**行そのもの**をマウントします: `<template data-wcs="for: users"><user-row data-wcs="state: ."></user-row></template>`。行コンポーネントの中の `name` は `users.*.name`、中の `for: tags` は `users.*.tags.*` を回します
|
|
1295
|
+
- **自前のキーは私有**です([docs/state-mount-design.md](../../docs/state-mount-design.md) §4-3 の R1): コンポーネントが自分で宣言したデータキー(`state = { mode: "view" }`)はその要素のもので、ツリーには書かれません。マウント先に同名のキーがあってそれを隠す形(`user.name` の上に `state = { name: "" }`)では、ランタイムが 1 回だけ warn します(`wcs/mount-own-key-shadow`)— ツリーを読みたければ既定値を消し、私有のままにしたければ名前を変えてください
|
|
1296
|
+
- 配列そのものをルートにマウントする形(`state: rows` + 中で `for`)は 1.x では非対応です。行をマウントする(`state: .`)か、配列を持つオブジェクトをマウントして中で `for` を回してください(`state: group` + `for: children`)。どちらも契約テストで固定されており、マウントがツリー拡張の唯一の手段になる v2 にそのまま引き継がれます
|
|
1297
|
+
|
|
1298
|
+
> プロパティ単位の形(`state.message: user.name`)はそのまま動きます。マップされるキーに既定値を宣言しているコンポーネント(`state = { message: "" }` + `state.message: ...`)には 1.x で 1 回だけ warn が出ます: 今日はホストの値が勝ちますが、v2 では自前のキーが私有になりホストの値を隠すので、既定値を消してください。
|
|
1299
|
+
|
|
1195
1300
|
### 独立した Web Component への状態注入(`__e2e__/single-component`)
|
|
1196
1301
|
|
|
1197
1302
|
ホストの外部状態に依存しないコンポーネントでも、`bind-component` で `state` を注入してリアクティブにできます。
|
|
@@ -1239,6 +1344,11 @@ customElements.define("my-component", MyComponent);
|
|
|
1239
1344
|
<template data-wcs="for: users">
|
|
1240
1345
|
<my-component data-wcs="state.message: .name"></my-component>
|
|
1241
1346
|
</template>
|
|
1347
|
+
|
|
1348
|
+
<!-- または行そのものをマウントする: コンポーネントの中の `name` は `users.*.name` -->
|
|
1349
|
+
<template data-wcs="for: users">
|
|
1350
|
+
<user-row data-wcs="state: ."></user-row>
|
|
1351
|
+
</template>
|
|
1242
1352
|
```
|
|
1243
1353
|
|
|
1244
1354
|
### コンポーネント側でリストを描画する
|
|
@@ -1750,6 +1860,8 @@ $updatedCallback(paths) {
|
|
|
1750
1860
|
| ハンドラ間 | `$watch` の宣言順 | **宣言を並べ替える** |
|
|
1751
1861
|
| 同一パスの行間 | `indexes` 昇順 | 固定 |
|
|
1752
1862
|
|
|
1863
|
+
**機構間の層を動かす唯一のもの**が、`state` 参加者を受け付ける `<wcs-view-transition>` です。バインディング適用 —— したがって `$updatedCallback` —— がフレームで着地する一方、`$watch` と `$streams` restart は state アドレスを消費し DOM を見ないので、drain がキューされた microtask に留まります。タグがある間の順序は `$watch` → `$streams` restart → `$updatedCallback` です。この層を並べ替えるものはページ上でこれ 1 つだけです。[docs/timing-and-firing-contract.ja.md](https://github.com/wcstack/wcstack/blob/main/docs/timing-and-firing-contract.ja.md) §4.3 を参照してください。
|
|
1864
|
+
|
|
1753
1865
|
主なルール:
|
|
1754
1866
|
|
|
1755
1867
|
- **自 state のみ** —— パスに `@stateName` は書けません。他の state 要素の watch は宣言時に拒否されます。
|
|
@@ -2011,6 +2123,31 @@ export default {
|
|
|
2011
2123
|
- `$updatedCallback(paths, indexesListByPath)` は、その drain で live binding が適用された path の一覧を受け取ります。binding のない state 書き込みでは呼ばれず、`paths` にも現れません。ワイルドカードをもつパスが更新された場合は、`indexesListByPath` から対象のインデックス情報も取得可能です。`async` を使用できますが、戻り値は await されません。
|
|
2012
2124
|
- Web Component を使用している場合は、コンポーネント側に `async $stateReadyCallback(stateProp)` を定義おくことで、`bind-component` でバインドした状態が利用可能になった瞬間にフックとして呼び出されます。
|
|
2013
2125
|
|
|
2126
|
+
## 遷移アニメーション
|
|
2127
|
+
|
|
2128
|
+
入場アニメーションにこのパッケージは要らない。新しい `for` 行も mount する `if` 分岐も「新しく挿入された要素」なので、素の CSS で足りる。
|
|
2129
|
+
|
|
2130
|
+
```css
|
|
2131
|
+
li {
|
|
2132
|
+
transition: opacity 0.2s, transform 0.2s;
|
|
2133
|
+
@starting-style { opacity: 0; transform: translateY(-4px); }
|
|
2134
|
+
}
|
|
2135
|
+
```
|
|
2136
|
+
|
|
2137
|
+
そこへ届かないのが**退場**と**移動**。削除された行は同期で detach され、並べ替えには中間状態が無い。[`@wcstack/view-transition`](https://github.com/wcstack/wcstack/tree/main/packages/view-transition) を足すと drain の DOM 変更が View Transition の中で行われ、変更前の状態はブラウザがスナップショットしてくれる。
|
|
2138
|
+
|
|
2139
|
+
```html
|
|
2140
|
+
<script type="module" src="https://esm.run/@wcstack/view-transition/auto"></script>
|
|
2141
|
+
<wcs-view-transition naming="auto"></wcs-view-transition>
|
|
2142
|
+
```
|
|
2143
|
+
|
|
2144
|
+
そのタグが `state` 参加者を受け付けている間、知っておくべき帰結が 2 つある。
|
|
2145
|
+
|
|
2146
|
+
- drain は microtask ではなくフレームで着地する。state に書いてから `await Promise.resolve()` で DOM を読むコードは遷移を待つ必要がある。`$updatedCallback` はバインディング適用の直後という*位置*こそ変わらないが、その適用ごと 1 フレーム後ろへずれる。
|
|
2147
|
+
- `$watch` と `$streams` restart は元の microtask に留まるため、`$updatedCallback` の**前**に走るようになる。
|
|
2148
|
+
|
|
2149
|
+
適用すべきバインディングが実際にあるバッチだけがタグへ渡されるので、headless なパスへの書き込みが遷移を起こすことはない。タグが無ければ drain は従来どおり。[docs/timing-and-firing-contract.ja.md](https://github.com/wcstack/wcstack/blob/main/docs/timing-and-firing-contract.ja.md) §4.3 参照。
|
|
2150
|
+
|
|
2014
2151
|
## 診断と失敗の扱い
|
|
2015
2152
|
|
|
2016
2153
|
### 存在しないパスへの配線は報告されます
|
|
@@ -2045,7 +2182,7 @@ dropped. Validate statically: npx @wcstack/lint <file>.
|
|
|
2045
2182
|
|
|
2046
2183
|
| 診断 | 何を見るか | 直し方 |
|
|
2047
2184
|
|---|---|---|
|
|
2048
|
-
| `wcs/index-arity` | `$resolve(path, indexes)` は `*` の本数と**厳密一致**、`$getAll(path, indexes)` は**上限**(不足は「残りの階層を全展開」という正当な接頭辞) | 本数を合わせる |
|
|
2185
|
+
| `wcs/index-arity` | `$resolve(path, indexes)` は `*` の本数と**厳密一致**、`$getAll(path, indexes)` / `$setAll(path, indexes, …)` は**上限**(不足は「残りの階層を全展開」という正当な接頭辞) | 本数を合わせる |
|
|
2049
2186
|
| `wcs/wildcard-rank` | パスの `*` の本数(と `$N` の N)が、囲む `for` の段数を超えていないか | `for` を足すか、`$resolve(path, indexes)` で行を明示する |
|
|
2050
2187
|
| `wcs/getter-cycle` | パス getter どうしが循環参照していないか | 循環を断つ |
|
|
2051
2188
|
|
|
@@ -2102,13 +2239,41 @@ bootstrapState({
|
|
|
2102
2239
|
|---|---|---|
|
|
2103
2240
|
| `bindAttributeName` | `'data-wcs'` | バインディング属性名 |
|
|
2104
2241
|
| `tagNames.state` | `'wcs-state'` | 状態要素のタグ名 |
|
|
2105
|
-
| `locale` | `'en'` |
|
|
2242
|
+
| `locale` | `<html lang>`、無ければ `'en'` | ロケール依存フィルタ(`locale` / `date` / `time` / `datetime`)のロケール — [ロケール](#ロケール)を参照 |
|
|
2106
2243
|
| `debug` | `false` | デバッグモード |
|
|
2107
2244
|
| `enableMustache` | `true` | `{{ }}` 構文の有効化 |
|
|
2108
2245
|
| `enableDirectionalInitialSync` | `true` | 方向認識のバインディング authority(`#init=` / `#sync=` バインド modifier)— [バインディング authority](#バインディング-authority-init--sync) 参照。既定 on。`false` で opt-out |
|
|
2109
2246
|
| `enablePropagationContext` | `true` | バインド間の因果伝播トラッキング(echo/diamond のループ防止)。既定 on。`false` で opt-out |
|
|
2110
2247
|
| `enableContractAnalyzer` | `false` | opt-in の開発時 contract analyzer(`analyzeContract` を公開) |
|
|
2111
2248
|
|
|
2249
|
+
### ロケール
|
|
2250
|
+
|
|
2251
|
+
ロケールで書式化するフィルタは 4 つある — `locale` / `date` / `time` / `datetime`。
|
|
2252
|
+
これらは `config.locale` を読み、その既定は **`<html lang>`** である。
|
|
2253
|
+
|
|
2254
|
+
```html
|
|
2255
|
+
<html lang="ja-JP">
|
|
2256
|
+
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
|
|
2257
|
+
```
|
|
2258
|
+
|
|
2259
|
+
他に何も要らない。`<html lang>` はページの言語を書く HTML 標準の場所であり、
|
|
2260
|
+
そこを既定にすればロケールの正本が 1 つで済む。同時に、**CDN 一発のページが
|
|
2261
|
+
ロケールを設定できるようになる** — `auto` は `bootstrapState()` を引数なしで呼ぶので、
|
|
2262
|
+
これが無いと渡す口が無かった。明示指定(`bootstrapState({ locale })`)は常に優先し、
|
|
2263
|
+
不正な BCP-47 タグは `Intl` の中で落ちる前に警告して無視する。
|
|
2264
|
+
|
|
2265
|
+
**`config.locale` を後から変えても何も再描画されない。** これは state ではなく
|
|
2266
|
+
グローバル設定なので、依存グラフに載らない。フィルタ自体はバインド構築時に
|
|
2267
|
+
取り込むのではなく**適用のたびに読む**ので、別の理由で再描画されたバインドは新しい値を
|
|
2268
|
+
拾う — 起動順序の事故から復帰するには足りるが、ページの言語を切り替えるには足りない。
|
|
2269
|
+
言語はページが描画される前に決めること。マークアップに `<html lang>` を書くか、
|
|
2270
|
+
`<head>` の同期スクリプトで書けば構造的にそうなる。
|
|
2271
|
+
|
|
2272
|
+
呼び出しごとの上書き(`price|locale(fr-FR)`)は従来どおり使え、こちらはバインド式の
|
|
2273
|
+
一部なのでバインド時に固定される。リロードなしで言語を切り替えたい場合は
|
|
2274
|
+
[docs/i18n-design.md](../../docs/i18n-design.md) を参照。短く言えば、翻訳はフィルタでは
|
|
2275
|
+
なくパスに置く。
|
|
2276
|
+
|
|
2112
2277
|
> この 3 つは **architecture-hardening** 機能で、規範は `docs/architecture-hardening/` に
|
|
2113
2278
|
> あります。`enablePropagationContext` は**既定 on** — write-path コストは一方向バインドで
|
|
2114
2279
|
> ほぼゼロ(echo しうる双方向 wire のみ因果 bookkeeping を行う)で、フラグは恒久的な
|
|
@@ -2121,6 +2286,122 @@ bootstrapState({
|
|
|
2121
2286
|
> が稼働中の `static wcBindable` サーフェスと sidecar manifest の drift を開発時診断として
|
|
2122
2287
|
> 報告します。
|
|
2123
2288
|
|
|
2289
|
+
## ページをテストする
|
|
2290
|
+
|
|
2291
|
+
`<wcs-state>` で組んだページは素の DOM なので、[happy-dom](https://github.com/capricorn86/happy-dom) でヘッドレスにテストできます — ブラウザ不要・ビルド不要・テスト専用 API 不要。レシピは 3 つ、いずれも書いてあるとおりに動きます(レシピ 1 は同じ行を実行する [`__tests__/readme.testingRecipe.test.ts`](__tests__/readme.testingRecipe.test.ts) で固定しています)。
|
|
2292
|
+
|
|
2293
|
+
1 import で済ませたいなら [`@wcstack/testing`](../testing/README.ja.md) がレシピ 1 を `mount()` / `settle()` / `fire()` にまとめています(`<wcs-router>` も待ちます)。以下の素のレシピはそれ無しでも有効です。
|
|
2294
|
+
|
|
2295
|
+
### 1. vitest + happy-dom
|
|
2296
|
+
|
|
2297
|
+
`vitest.config.ts`:
|
|
2298
|
+
|
|
2299
|
+
```ts
|
|
2300
|
+
import { defineConfig } from "vitest/config";
|
|
2301
|
+
|
|
2302
|
+
export default defineConfig({
|
|
2303
|
+
test: { environment: "happy-dom", setupFiles: ["./tests/setup.ts"] },
|
|
2304
|
+
});
|
|
2305
|
+
```
|
|
2306
|
+
|
|
2307
|
+
`tests/setup.ts` — 要素の登録を 1 回だけ行い、インラインの `<script type="module">` state を `data:` URL ローダーに回します(Node は `blob:` URL を import できないため、この行が無いとインライン script の state は永久に読み込み中になります):
|
|
2308
|
+
|
|
2309
|
+
```ts
|
|
2310
|
+
import { bootstrapState } from "@wcstack/state";
|
|
2311
|
+
|
|
2312
|
+
bootstrapState();
|
|
2313
|
+
URL.createObjectURL = undefined as any;
|
|
2314
|
+
```
|
|
2315
|
+
|
|
2316
|
+
テスト:
|
|
2317
|
+
|
|
2318
|
+
```ts
|
|
2319
|
+
import { expect, it } from "vitest";
|
|
2320
|
+
import { getBindingsReady } from "@wcstack/state";
|
|
2321
|
+
|
|
2322
|
+
const settle = () => new Promise<void>((r) => setTimeout(r, 0));
|
|
2323
|
+
|
|
2324
|
+
it("描画・再描画・ハンドラ実行", async () => {
|
|
2325
|
+
// 1. テスト対象の断片をマウント
|
|
2326
|
+
document.body.innerHTML = `
|
|
2327
|
+
<wcs-state json='{"count": 1, "items": ["apple", "banana"]}'></wcs-state>
|
|
2328
|
+
<p id="count" data-wcs="textContent: count"></p>
|
|
2329
|
+
<ul id="items">
|
|
2330
|
+
<template data-wcs="for: items">
|
|
2331
|
+
<li data-wcs="textContent: items.*"></li>
|
|
2332
|
+
</template>
|
|
2333
|
+
</ul>
|
|
2334
|
+
`;
|
|
2335
|
+
|
|
2336
|
+
// 2. state 要素を待ち、続けて `document` 配下の全バインドを待つ
|
|
2337
|
+
const stateEl = document.querySelector("wcs-state") as any;
|
|
2338
|
+
await stateEl.connectedCallbackPromise;
|
|
2339
|
+
await getBindingsReady(document);
|
|
2340
|
+
|
|
2341
|
+
// 3. 初期描画を検証
|
|
2342
|
+
expect(document.querySelector("#count")!.textContent).toBe("1");
|
|
2343
|
+
expect(document.querySelectorAll("#items li").length).toBe(2);
|
|
2344
|
+
|
|
2345
|
+
// 4. writable プロキシ経由で書く — ハンドラがやっていることと同じ
|
|
2346
|
+
await stateEl.createStateAsync("writable", async (state: any) => {
|
|
2347
|
+
state.count = 42;
|
|
2348
|
+
state.items = [...state.items, "cherry"];
|
|
2349
|
+
});
|
|
2350
|
+
await settle();
|
|
2351
|
+
|
|
2352
|
+
// 5. 再描画を検証
|
|
2353
|
+
expect(document.querySelector("#count")!.textContent).toBe("42");
|
|
2354
|
+
expect(document.querySelectorAll("#items li").length).toBe(3);
|
|
2355
|
+
});
|
|
2356
|
+
```
|
|
2357
|
+
|
|
2358
|
+
ユーザー操作と同じ経路で動かすなら、state はインライン(メソッド込み)のまま DOM イベントを発火します。`data-wcs="onclick: up"` のハンドラは `button.click()` で走り、`settle()` 1 回の後に DOM へ反映されます。
|
|
2359
|
+
|
|
2360
|
+
- `getBindingsReady(root)` は `root`(`document` か shadow root)配下の全バインド構築が終わると resolve し、バインド初期化に失敗すると reject します(v1.26+)。
|
|
2361
|
+
- 更新はマイクロタスク境界で収束します。書き込み後の `setTimeout(0)` 1 回で十分です。
|
|
2362
|
+
- `state.items = [...state.items, "cherry"]` がリアクティブな書き方です — `state.items.push()` は観測されません(ハンドラ内と同じ規則)。
|
|
2363
|
+
- happy-dom は `customElements.define` 時に既存ノードを**差し替えて**アップグレードします。「遅れて define された同一ノードに値が届く」はヘッドレスでは検証できません。happy-dom と実ブラウザのイベントタイミング差ももう 1 つの死角なので、そこは実ブラウザ e2e(Playwright)を 1 本残してください。
|
|
2364
|
+
- happy-dom の `textContent` setter は数値 `0` を空文字にします(ブラウザは `"0"`)。このレシピでは `textContent: count` のバインドが 0 のとき `""` に読めます。state の値で assert するか、setter をシムする `@wcstack/testing` の `mount()` を使ってください。
|
|
2365
|
+
|
|
2366
|
+
### 2. 素の Node(vitest なし)
|
|
2367
|
+
|
|
2368
|
+
`@wcstack/server` が SSR に使っているグローバル差し替えをそのまま export しているので再利用します。**`@wcstack/state` は `installGlobals` の後に動的 import** してください — 要素クラスはモジュール評価時に基底クラスを決めるので、ファイル先頭で静的 import すると happy-dom が構築できない要素が登録されます:
|
|
2369
|
+
|
|
2370
|
+
```js
|
|
2371
|
+
import { Window } from "happy-dom";
|
|
2372
|
+
import { installGlobals } from "@wcstack/server";
|
|
2373
|
+
|
|
2374
|
+
const window = new Window({ url: "http://localhost/" });
|
|
2375
|
+
const restore = installGlobals(window); // document, customElements, HTMLElement, ...(GLOBALS_KEYS)
|
|
2376
|
+
try {
|
|
2377
|
+
const { bootstrapState, getBindingsReady } = await import("@wcstack/state");
|
|
2378
|
+
bootstrapState();
|
|
2379
|
+
// ... 以降はレシピ 1 と同じ mount / await / assert
|
|
2380
|
+
} finally {
|
|
2381
|
+
restore();
|
|
2382
|
+
await window.happyDOM.close();
|
|
2383
|
+
}
|
|
2384
|
+
```
|
|
2385
|
+
|
|
2386
|
+
`installGlobals` は `URL.createObjectURL` の無効化も行うので、インライン script の state はレシピ 1 と同じ経路で読み込まれます。
|
|
2387
|
+
|
|
2388
|
+
### 3. 描画結果のスナップショット
|
|
2389
|
+
|
|
2390
|
+
`@wcstack/server` の [`renderToString()`](../server/README.ja.md) は描画済みマークアップを文字列で返します。保存したスナップショットと比較してください:
|
|
2391
|
+
|
|
2392
|
+
```ts
|
|
2393
|
+
import { expect, it } from "vitest";
|
|
2394
|
+
import { renderToString } from "@wcstack/server";
|
|
2395
|
+
|
|
2396
|
+
it("描画結果がスナップショットと一致する", async () => {
|
|
2397
|
+
const html = await renderToString(`
|
|
2398
|
+
<wcs-state json='{"items": ["apple", "banana"]}' enable-ssr></wcs-state>
|
|
2399
|
+
<ul><template data-wcs="for: items"><li data-wcs="textContent: items.*"></li></template></ul>
|
|
2400
|
+
`);
|
|
2401
|
+
expect(html).toMatchSnapshot();
|
|
2402
|
+
});
|
|
2403
|
+
```
|
|
2404
|
+
|
|
2124
2405
|
## TypeScript サポート
|
|
2125
2406
|
|
|
2126
2407
|
`defineState()` で状態オブジェクトをラップすると、メソッドや getter 内の `this` に型補完が効きます。ランタイムコストはゼロ(アイデンティティ関数)です。
|