@wcstack/state 1.32.0 → 2.0.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 +194 -37
- package/README.md +193 -37
- package/dist/auto.min.js +1 -1
- package/dist/auto.min.js.map +1 -1
- package/dist/index.d.ts +195 -26
- package/dist/index.esm.js +2711 -1323
- package/dist/index.esm.js.map +1 -1
- package/dist/manifest.d.ts +1 -2
- package/dist/manifest.esm.js +1 -3
- package/dist/parser.d.ts +0 -1
- package/dist/parser.esm.js +8 -7
- package/dist/wcs-manifest.json +0 -1
- package/package.json +1 -1
package/README.ja.md
CHANGED
|
@@ -46,13 +46,13 @@
|
|
|
46
46
|
|----------|----------------|--------------|
|
|
47
47
|
| **状態** (`<wcs-state>`) | データ構造とビジネスロジック | どのDOM要素がバインドされているか |
|
|
48
48
|
| **UI** (`data-wcs`) | パス文字列と表示意図 | 状態がどう保存・算出されているか |
|
|
49
|
-
| **コンポーネント** (
|
|
49
|
+
| **コンポーネント** (`state: path`) | ホストが書くマウント表 | 他コンポーネントの内部実装 |
|
|
50
50
|
|
|
51
51
|
3つのレベルのパス契約が疎結合を実現しています:
|
|
52
52
|
|
|
53
53
|
1. **UI ↔ 状態** — `data-wcs="textContent: user.name"` という属性がバインディングのすべてです。フックもセレクタもリアクティブプリミティブもありません。コンポーネントのJavaScriptには、状態を参照するコードが**一行も**必要ありません。
|
|
54
54
|
|
|
55
|
-
2. **コンポーネント ↔ コンポーネント** —
|
|
55
|
+
2. **コンポーネント ↔ コンポーネント** — ホストが各コンポーネントへ部分木をマウントし(`<my-card data-wcs="state: user">`)、ボリュームが追加モジュールをツリーに接ぎ木します(`<wcs-state mount="i18n">`)。コンポーネント同士がお互いを直接インポートしたり参照したりすることはありません。すべての接続は単一ツリー上のパス接頭辞だけです。
|
|
56
56
|
|
|
57
57
|
3. **ループコンテキスト** — `for` ループ内では `*` が抽象インデックスとして機能します。`items.*.price` のようなバインディングは自動的に現在の要素へと解決されます。テンプレートは自身の具体的な位置(インデックス)を知る必要がなく、ワイルドカードがその契約となります。
|
|
58
58
|
|
|
@@ -222,19 +222,20 @@
|
|
|
222
222
|
|
|
223
223
|
> **Content-Security-Policy 下では:** 5 番(内包 `<script type="module">`)は `blob:` URL 経由で評価されるため `script-src blob:` が必要です。ページの nonce では救えません。厳格な CSP を敷く場合は 4 番(`src="./state.js"`)を使ってください。追加ディレクティブは不要です。詳細は [docs/csp.ja.md](../../docs/csp.ja.md)。
|
|
224
224
|
|
|
225
|
-
###
|
|
225
|
+
### 追加の状態をマウントする(`mount=`)
|
|
226
226
|
|
|
227
|
-
|
|
227
|
+
状態のツリーは **1 つの root に 1 本**です。状態をモジュールに分けたい場合はボリュームをマウントします。データはマウントパスの位置でルートツリーに接ぎ木され、バインディングは接頭辞付きのパスで読みます。
|
|
228
228
|
|
|
229
229
|
```html
|
|
230
|
-
<wcs-state
|
|
231
|
-
<wcs-state
|
|
230
|
+
<wcs-state mount="cart" src="./cart.js"></wcs-state>
|
|
231
|
+
<wcs-state src="./app.js"></wcs-state>
|
|
232
232
|
|
|
233
|
-
<div data-wcs="textContent: total
|
|
234
|
-
<div data-wcs="textContent: name@user"></div>
|
|
233
|
+
<div data-wcs="textContent: cart.total"></div>
|
|
235
234
|
```
|
|
236
235
|
|
|
237
|
-
|
|
236
|
+
ボリュームは getter・`$watch`・`$listKeys`・`$updatedCallback`・`$connectedCallback`/`$disconnectedCallback` を宣言できます(すべてマウントパス相対)。読み込み順は自由です(ルートより先に接続されたボリュームは、ルートの登録時に接ぎ木されます)。マウントパスは静的パスのみです(`*`・`$`・`#`・`@` は不可)。
|
|
237
|
+
|
|
238
|
+
> **v1 の名前付き状態からの移行:** `<wcs-state name="cart">` + `total@cart` は `<wcs-state mount="cart">` + `cart.total` になります。v2 では `name` 属性は fail-fast し、パス中の `@` は parse error です(どちらもこの誘導文付き)。移行の対応表: [docs/state-mount-design.md](../../docs/state-mount-design.md) §9。
|
|
238
239
|
|
|
239
240
|
## 状態の更新
|
|
240
241
|
|
|
@@ -287,7 +288,7 @@ this.items.sort((a, b) => a.id - b.id);
|
|
|
287
288
|
### `data-wcs` 属性
|
|
288
289
|
|
|
289
290
|
```
|
|
290
|
-
property[#modifier]: path[
|
|
291
|
+
property[#modifier]: path[|filter[|filter(args)...]]
|
|
291
292
|
```
|
|
292
293
|
|
|
293
294
|
複数バインディングは `;` で区切ります:
|
|
@@ -301,7 +302,6 @@ property[#modifier]: path[@state][|filter[|filter(args)...]]
|
|
|
301
302
|
| `property` | バインドする DOM プロパティ | `value`, `textContent`, `checked` |
|
|
302
303
|
| `#modifier` | バインディング修飾子 | `#ro`, `#prevent`, `#stop`, `#onchange` |
|
|
303
304
|
| `path` | 状態プロパティパス | `count`, `user.name`, `users.*.name` |
|
|
304
|
-
| `@state` | 名前付き状態の参照 | `@cart`, `@user` |
|
|
305
305
|
| `\|filter` | 変換フィルタチェーン | `\|gt(0)`, `\|round\|locale` |
|
|
306
306
|
|
|
307
307
|
### プロパティ種別
|
|
@@ -486,7 +486,7 @@ export default {
|
|
|
486
486
|
|
|
487
487
|
- spread 右辺へのフィルタ(`...: target|filter`)はエラー
|
|
488
488
|
- 右辺パスの途中に `*` を含めても OK(例:`...: stores.*.fetch`)
|
|
489
|
-
-
|
|
489
|
+
- 右辺は素のツリーパス(`...: fetchX`、途中の `*` も可)
|
|
490
490
|
- カスタム要素クラスが未登録の場合、`customElements.whenDefined(tag)` 解決時に遅延展開される(autoloader による遅延ロードに対応)
|
|
491
491
|
- `wcBindable` 宣言**のない**要素はエラー(明示配線で書いてください)。spread は何を展開すべきかを契約から読み取るため
|
|
492
492
|
|
|
@@ -555,7 +555,6 @@ this.items = await (await fetch("/api/items")).json();
|
|
|
555
555
|
| `.name` | `users.*.name` | 現在の要素のプロパティ |
|
|
556
556
|
| `.` | `users.*` | 現在の要素そのもの |
|
|
557
557
|
| `.name\|uc` | `users.*.name\|uc` | フィルタは保持される |
|
|
558
|
-
| `.name@state` | `users.*.name@state` | 状態名は保持される |
|
|
559
558
|
|
|
560
559
|
プリミティブ配列では、`.` が要素の値を直接参照します:
|
|
561
560
|
|
|
@@ -1202,7 +1201,7 @@ customElements.define("my-component", MyComponent);
|
|
|
1202
1201
|
|
|
1203
1202
|
### コンポーネント定義(Light DOM)
|
|
1204
1203
|
|
|
1205
|
-
Light DOM コンポーネントは Shadow DOM を使用しません。
|
|
1204
|
+
Light DOM コンポーネントは Shadow DOM を使用しません。v2 では Shadow 形と同じ書き方になります —— コンポーネントのバインディングはマウント位置でホストのツリーへ変換されるため、**name も `@` セレクタも不要**で、同じコンポーネントをリストの行ごとに置けます:
|
|
1206
1205
|
|
|
1207
1206
|
```javascript
|
|
1208
1207
|
class MyLightComponent extends HTMLElement {
|
|
@@ -1210,30 +1209,21 @@ class MyLightComponent extends HTMLElement {
|
|
|
1210
1209
|
|
|
1211
1210
|
connectedCallback() {
|
|
1212
1211
|
this.innerHTML = `
|
|
1213
|
-
<wcs-state bind-component="state"
|
|
1214
|
-
<div data-wcs="text: message
|
|
1215
|
-
<input type="text" data-wcs="value: message
|
|
1212
|
+
<wcs-state bind-component="state"></wcs-state>
|
|
1213
|
+
<div data-wcs="text: message"></div>
|
|
1214
|
+
<input type="text" data-wcs="value: message" />
|
|
1216
1215
|
`;
|
|
1217
1216
|
}
|
|
1218
1217
|
}
|
|
1219
1218
|
customElements.define("my-light-component", MyLightComponent);
|
|
1220
1219
|
```
|
|
1221
1220
|
|
|
1222
|
-
- Light DOM コンポーネントでは `name` 属性が**必須**です(名前空間が上位スコープと共有されるため)
|
|
1223
|
-
- バインディングでは `@my-light` のように状態名を明示的に参照する必要があります
|
|
1224
1221
|
- `<wcs-state>` はコンポーネント要素の直下に配置する必要があります
|
|
1222
|
+
- **ホストからの配線が必須**です(`<my-light-component data-wcs="state.message: user.name">` または `state: user`)。配線の無い plain な Light DOM `bind-component` は v2 では成立しません(親と root を共有したまま独立ツリーは持てない)—— 誘導文付きで loud に失敗します: shadow を付けるか、ホストから配線してマウントにしてください。
|
|
1225
1223
|
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
> **注意**: Light DOM は名前空間を上位スコープと共有するため、**同じ `name` を持つインスタンスを
|
|
1231
|
-
> 同一スコープに複数置くことはできません**。リストの行ごとにコンポーネントを配置するような形は
|
|
1232
|
-
> Shadow DOM を使ってください。
|
|
1233
|
-
>
|
|
1234
|
-
> また `State.getBindingsReady(root)` はコンポーネントのスコープを含みません(Shadow DOM 形で
|
|
1235
|
-
> 子が別 rootNode にあるのと同じ扱いです)。コンポーネント内部の描画完了まで待ちたい場合は、
|
|
1236
|
-
> コンポーネント側の `<wcs-state>` の初期化を待ってください。
|
|
1224
|
+
> **注意**: `State.getBindingsReady(root)` はマウント記録の確定後、マウントスコープも待ちます。
|
|
1225
|
+
> コンポーネント内部の描画完了まで待ちたい場合は、コンポーネント側の `<wcs-state>` の初期化を
|
|
1226
|
+
> 待ってください。
|
|
1237
1227
|
|
|
1238
1228
|
### ホスト側の使用方法
|
|
1239
1229
|
|
|
@@ -1254,6 +1244,54 @@ customElements.define("my-light-component", MyLightComponent);
|
|
|
1254
1244
|
- `data-wcs="state.message: user.name"` でホスト要素上の外部状態パスを内部コンポーネント状態プロパティにバインド
|
|
1255
1245
|
- 変更はコンポーネントと外部状態間で双方向に伝播
|
|
1256
1246
|
|
|
1247
|
+
### 丸ごとマウント(`state: path`)
|
|
1248
|
+
|
|
1249
|
+
プロパティ単位で配線する代わりに、ホストは自分の状態の**サブツリーを丸ごと**コンポーネントのルートとしてマウントできます。コンポーネントの中のパスは、すべてマウント先からの相対になります:
|
|
1250
|
+
|
|
1251
|
+
```html
|
|
1252
|
+
<!-- ホスト側 -->
|
|
1253
|
+
<wcs-state json='{"user":{"name":"Alice","email":"alice@example.com"},"theme":{"mode":"light"}}'></wcs-state>
|
|
1254
|
+
<user-card data-wcs="state: user"></user-card>
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
```javascript
|
|
1258
|
+
// コンポーネント側(Shadow DOM)
|
|
1259
|
+
class UserCard extends HTMLElement {
|
|
1260
|
+
state = {
|
|
1261
|
+
// マウント先の上で計算する getter — `this.name` はツリーの `user.name`
|
|
1262
|
+
get display() { return `${this.name} <${this.email}>`; },
|
|
1263
|
+
};
|
|
1264
|
+
constructor() {
|
|
1265
|
+
super();
|
|
1266
|
+
this.attachShadow({ mode: "open" });
|
|
1267
|
+
}
|
|
1268
|
+
connectedCallback() {
|
|
1269
|
+
this.shadowRoot.innerHTML = `
|
|
1270
|
+
<wcs-state bind-component="state"></wcs-state>
|
|
1271
|
+
<span data-wcs="textContent: name"></span>
|
|
1272
|
+
<span data-wcs="textContent: display"></span>
|
|
1273
|
+
<input data-wcs="value: name">
|
|
1274
|
+
`;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
customElements.define("user-card", UserCard);
|
|
1278
|
+
```
|
|
1279
|
+
|
|
1280
|
+
- `state: user` はコンポーネントのルートをツリーのパス `user` に置きます。中の `name` は `user.name` **そのもの**です。読み・書き(`value: name`、`this.state.name = ...`)・getter・`for:` はすべてツリーに対して解決され、ホストの丸ごと差し替え(`this.user = {...}`)も部分書き込み(`this["user.name"] = ...`)もコンポーネントに届きます
|
|
1281
|
+
- 部分マウントを併用できます: `state: user; state.theme: theme` は `theme` を 2 つ目の入口としてマウントします(最長接頭辞が勝つので、中の `theme.mode` はツリーの `theme.mode` を読みます)
|
|
1282
|
+
- ループでは**行そのもの**をマウントします: `<template data-wcs="for: users"><user-row data-wcs="state: ."></user-row></template>`。行コンポーネントの中の `name` は `users.*.name`、中の `for: tags` は `users.*.tags.*` を回します
|
|
1283
|
+
- **自前のキーは私有**です([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`)— ツリーを読みたければ既定値を消し、私有のままにしたければ名前を変えてください
|
|
1284
|
+
- 配列そのものをルートにマウントする形(`state: rows` + 中で `for`)は非対応です。行をマウントする(`state: .`)か、配列を持つオブジェクトをマウントして中で `for` を回してください(`state: group` + `for: children`)。どちらも契約テストで固定されており、マウントがツリー拡張の唯一の手段です
|
|
1285
|
+
|
|
1286
|
+
> プロパティ単位の形(`state.message: user.name`)はそのまま動きます — 同じ機構の上の部分マウントです。
|
|
1287
|
+
> R1 はすべてのマウント形で厳格です — マップされるキーに既定値を
|
|
1288
|
+
> 宣言しているコンポーネント(`state = { message: "" }` + `state.message: ...`)は自前のキーが
|
|
1289
|
+
> **私有**になり、ホストの値を隠します(1 回だけ `wcs/mount-own-key-shadow` が指します)。ツリーを
|
|
1290
|
+
> 読むには既定値を消してください。Light DOM のマウントに `name` は不要で、
|
|
1291
|
+
> `element.state`(および getter / メソッド内の `this`)の `$getAll` / `$setAll` / `$resolve` /
|
|
1292
|
+
> `$postUpdate` はコンポーネント自身の語彙で書けます — パスはマウント先へ翻訳され、ホスト行の
|
|
1293
|
+
> 添字は自動で前置されます。
|
|
1294
|
+
|
|
1257
1295
|
### 独立した Web Component への状態注入(`__e2e__/single-component`)
|
|
1258
1296
|
|
|
1259
1297
|
ホストの外部状態に依存しないコンポーネントでも、`bind-component` で `state` を注入してリアクティブにできます。
|
|
@@ -1292,8 +1330,7 @@ customElements.define("my-component", MyComponent);
|
|
|
1292
1330
|
|
|
1293
1331
|
- `bind-component` 付きの `<wcs-state>` はコンポーネント要素の**直下**(トップレベル)に配置すること
|
|
1294
1332
|
- 親要素は**カスタム要素**(ハイフンを含むタグ名)であること
|
|
1295
|
-
- Light DOM
|
|
1296
|
-
- Light DOM のバインディングでは状態名を明示的に参照すること(例: `@my-light`)
|
|
1333
|
+
- Light DOM コンポーネントはホストからの配線が必須(plain 形は v2 で廃止)
|
|
1297
1334
|
|
|
1298
1335
|
### ループ内でのコンポーネント使用
|
|
1299
1336
|
|
|
@@ -1301,6 +1338,11 @@ customElements.define("my-component", MyComponent);
|
|
|
1301
1338
|
<template data-wcs="for: users">
|
|
1302
1339
|
<my-component data-wcs="state.message: .name"></my-component>
|
|
1303
1340
|
</template>
|
|
1341
|
+
|
|
1342
|
+
<!-- または行そのものをマウントする: コンポーネントの中の `name` は `users.*.name` -->
|
|
1343
|
+
<template data-wcs="for: users">
|
|
1344
|
+
<user-row data-wcs="state: ."></user-row>
|
|
1345
|
+
</template>
|
|
1304
1346
|
```
|
|
1305
1347
|
|
|
1306
1348
|
### コンポーネント側でリストを描画する
|
|
@@ -1816,13 +1858,13 @@ $updatedCallback(paths) {
|
|
|
1816
1858
|
|
|
1817
1859
|
主なルール:
|
|
1818
1860
|
|
|
1819
|
-
-
|
|
1861
|
+
- **ツリーのパスのみ** —— パスに `@`(v1 の名前セレクタ)は書けません。含む宣言は loud に拒否されます。
|
|
1820
1862
|
- **中間値は観測できません** —— 1 バッチ内の `a → b → c` は `cur = c` / `prev = a` で 1 回だけ発火します(binding 更新と同じ契約)。
|
|
1821
1863
|
- **行単位の差分を見たいなら `$listKeys`** —— 未宣言のまま配列全体を代入すると、行 watch は**全行**について `prev === undefined` で発火します(どの行もパス書き込みを通っていないため)。`$listKeys` を宣言すればキー突合が per-field 書き込みに分解するので、変化した行だけが発火し `prev` もスカラで取れます。
|
|
1822
1864
|
- **headless な行 watch には `$listKeys` が必要** —— `$watch` が単独では headless にならない唯一の箇所です。`items` から `items.*.price` への展開はリストの `for` バインディングが駆動しており、watch を宣言してもそのパスをリストとしては登録しません(意図的)。したがって `for` バインドも `$listKeys` も無い状態で配列を代入すると、行 watch は**一度も**発火しません。`$listKeys` を宣言する(キー突合がフィールドごとにパス書き込みするので展開を経由しない)か、リストを描画してください。スカラーパスは `user.name` のようなネストしたものも含め、この条件なしに headless で発火します。
|
|
1823
1865
|
- **ハンドラの例外は隔離されます** —— throw はコンソールに報告され、残りの watch(と stream の restart)は続行します。loud fail する `$connectedCallback` / `$updatedCallback` とは異なる扱いです。
|
|
1824
1866
|
- **書き込みの連鎖には上限があります** —— ハンドラの書き込みは新しいバッチを作るため、相互に書き合う watch は無限ループになり得ます。32 段で打ち切り、コンソールに報告します(値と DOM は巻き戻しません)。
|
|
1825
|
-
-
|
|
1867
|
+
- **マウントされた `bind-component` スコープでは実行されません** —— マウントされたコンポーネントは宣言面を実行せず、`$watch` の宣言があると 1 回だけ console.warn でルート state(またはボリューム —— `<wcs-state mount>` は `$watch` / `$listKeys` / `$updatedCallback` を持てます)へ誘導します(`$streams` も同様)。plain な(配線なし Shadow の)子は独立ツリーを持つので宣言できます。
|
|
1826
1868
|
- **SSR では実行されません** —— ハンドラの副作用がサーバーとクライアントで二重に走るためです。
|
|
1827
1869
|
|
|
1828
1870
|
## Inputs と属性ミラー
|
|
@@ -2123,7 +2165,7 @@ dropped. Validate statically: npx @wcstack/lint <file>.
|
|
|
2123
2165
|
- 親が `null` / `undefined`(初期値 `null` に後から代入する形)
|
|
2124
2166
|
- 初期値が空配列のリストの行フィールド(行の形が分からない)
|
|
2125
2167
|
- 途中の getter の戻り値のサブプロパティ
|
|
2126
|
-
-
|
|
2168
|
+
- マウントされたコンポーネントのマーカーパス(`#m…` —— 私有キー・getter の実体はマウントのオーバーレイ側にあり raw state には無い)
|
|
2127
2169
|
- `$` 始まりの予約名前空間(`$command.*` など)
|
|
2128
2170
|
|
|
2129
2171
|
裏を返すと、**警告が出ない = 正しい保証にはなりません**。網羅した検査は `npx @wcstack/lint <file>` 側で行ってください。
|
|
@@ -2238,6 +2280,122 @@ bootstrapState({
|
|
|
2238
2280
|
> が稼働中の `static wcBindable` サーフェスと sidecar manifest の drift を開発時診断として
|
|
2239
2281
|
> 報告します。
|
|
2240
2282
|
|
|
2283
|
+
## ページをテストする
|
|
2284
|
+
|
|
2285
|
+
`<wcs-state>` で組んだページは素の DOM なので、[happy-dom](https://github.com/capricorn86/happy-dom) でヘッドレスにテストできます — ブラウザ不要・ビルド不要・テスト専用 API 不要。レシピは 3 つ、いずれも書いてあるとおりに動きます(レシピ 1 は同じ行を実行する [`__tests__/readme.testingRecipe.test.ts`](__tests__/readme.testingRecipe.test.ts) で固定しています)。
|
|
2286
|
+
|
|
2287
|
+
1 import で済ませたいなら [`@wcstack/testing`](../testing/README.ja.md) がレシピ 1 を `mount()` / `settle()` / `fire()` にまとめています(`<wcs-router>` も待ちます)。以下の素のレシピはそれ無しでも有効です。
|
|
2288
|
+
|
|
2289
|
+
### 1. vitest + happy-dom
|
|
2290
|
+
|
|
2291
|
+
`vitest.config.ts`:
|
|
2292
|
+
|
|
2293
|
+
```ts
|
|
2294
|
+
import { defineConfig } from "vitest/config";
|
|
2295
|
+
|
|
2296
|
+
export default defineConfig({
|
|
2297
|
+
test: { environment: "happy-dom", setupFiles: ["./tests/setup.ts"] },
|
|
2298
|
+
});
|
|
2299
|
+
```
|
|
2300
|
+
|
|
2301
|
+
`tests/setup.ts` — 要素の登録を 1 回だけ行い、インラインの `<script type="module">` state を `data:` URL ローダーに回します(Node は `blob:` URL を import できないため、この行が無いとインライン script の state は永久に読み込み中になります):
|
|
2302
|
+
|
|
2303
|
+
```ts
|
|
2304
|
+
import { bootstrapState } from "@wcstack/state";
|
|
2305
|
+
|
|
2306
|
+
bootstrapState();
|
|
2307
|
+
URL.createObjectURL = undefined as any;
|
|
2308
|
+
```
|
|
2309
|
+
|
|
2310
|
+
テスト:
|
|
2311
|
+
|
|
2312
|
+
```ts
|
|
2313
|
+
import { expect, it } from "vitest";
|
|
2314
|
+
import { getBindingsReady } from "@wcstack/state";
|
|
2315
|
+
|
|
2316
|
+
const settle = () => new Promise<void>((r) => setTimeout(r, 0));
|
|
2317
|
+
|
|
2318
|
+
it("描画・再描画・ハンドラ実行", async () => {
|
|
2319
|
+
// 1. テスト対象の断片をマウント
|
|
2320
|
+
document.body.innerHTML = `
|
|
2321
|
+
<wcs-state json='{"count": 1, "items": ["apple", "banana"]}'></wcs-state>
|
|
2322
|
+
<p id="count" data-wcs="textContent: count"></p>
|
|
2323
|
+
<ul id="items">
|
|
2324
|
+
<template data-wcs="for: items">
|
|
2325
|
+
<li data-wcs="textContent: items.*"></li>
|
|
2326
|
+
</template>
|
|
2327
|
+
</ul>
|
|
2328
|
+
`;
|
|
2329
|
+
|
|
2330
|
+
// 2. state 要素を待ち、続けて `document` 配下の全バインドを待つ
|
|
2331
|
+
const stateEl = document.querySelector("wcs-state") as any;
|
|
2332
|
+
await stateEl.connectedCallbackPromise;
|
|
2333
|
+
await getBindingsReady(document);
|
|
2334
|
+
|
|
2335
|
+
// 3. 初期描画を検証
|
|
2336
|
+
expect(document.querySelector("#count")!.textContent).toBe("1");
|
|
2337
|
+
expect(document.querySelectorAll("#items li").length).toBe(2);
|
|
2338
|
+
|
|
2339
|
+
// 4. writable プロキシ経由で書く — ハンドラがやっていることと同じ
|
|
2340
|
+
await stateEl.createStateAsync("writable", async (state: any) => {
|
|
2341
|
+
state.count = 42;
|
|
2342
|
+
state.items = [...state.items, "cherry"];
|
|
2343
|
+
});
|
|
2344
|
+
await settle();
|
|
2345
|
+
|
|
2346
|
+
// 5. 再描画を検証
|
|
2347
|
+
expect(document.querySelector("#count")!.textContent).toBe("42");
|
|
2348
|
+
expect(document.querySelectorAll("#items li").length).toBe(3);
|
|
2349
|
+
});
|
|
2350
|
+
```
|
|
2351
|
+
|
|
2352
|
+
ユーザー操作と同じ経路で動かすなら、state はインライン(メソッド込み)のまま DOM イベントを発火します。`data-wcs="onclick: up"` のハンドラは `button.click()` で走り、`settle()` 1 回の後に DOM へ反映されます。
|
|
2353
|
+
|
|
2354
|
+
- `getBindingsReady(root)` は `root`(`document` か shadow root)配下の全バインド構築が終わると resolve し、バインド初期化に失敗すると reject します(v1.26+)。
|
|
2355
|
+
- 更新はマイクロタスク境界で収束します。書き込み後の `setTimeout(0)` 1 回で十分です。
|
|
2356
|
+
- `state.items = [...state.items, "cherry"]` がリアクティブな書き方です — `state.items.push()` は観測されません(ハンドラ内と同じ規則)。
|
|
2357
|
+
- happy-dom は `customElements.define` 時に既存ノードを**差し替えて**アップグレードします。「遅れて define された同一ノードに値が届く」はヘッドレスでは検証できません。happy-dom と実ブラウザのイベントタイミング差ももう 1 つの死角なので、そこは実ブラウザ e2e(Playwright)を 1 本残してください。
|
|
2358
|
+
- happy-dom の `textContent` setter は数値 `0` を空文字にします(ブラウザは `"0"`)。このレシピでは `textContent: count` のバインドが 0 のとき `""` に読めます。state の値で assert するか、setter をシムする `@wcstack/testing` の `mount()` を使ってください。
|
|
2359
|
+
|
|
2360
|
+
### 2. 素の Node(vitest なし)
|
|
2361
|
+
|
|
2362
|
+
`@wcstack/server` が SSR に使っているグローバル差し替えをそのまま export しているので再利用します。**`@wcstack/state` は `installGlobals` の後に動的 import** してください — 要素クラスはモジュール評価時に基底クラスを決めるので、ファイル先頭で静的 import すると happy-dom が構築できない要素が登録されます:
|
|
2363
|
+
|
|
2364
|
+
```js
|
|
2365
|
+
import { Window } from "happy-dom";
|
|
2366
|
+
import { installGlobals } from "@wcstack/server";
|
|
2367
|
+
|
|
2368
|
+
const window = new Window({ url: "http://localhost/" });
|
|
2369
|
+
const restore = installGlobals(window); // document, customElements, HTMLElement, ...(GLOBALS_KEYS)
|
|
2370
|
+
try {
|
|
2371
|
+
const { bootstrapState, getBindingsReady } = await import("@wcstack/state");
|
|
2372
|
+
bootstrapState();
|
|
2373
|
+
// ... 以降はレシピ 1 と同じ mount / await / assert
|
|
2374
|
+
} finally {
|
|
2375
|
+
restore();
|
|
2376
|
+
await window.happyDOM.close();
|
|
2377
|
+
}
|
|
2378
|
+
```
|
|
2379
|
+
|
|
2380
|
+
`installGlobals` は `URL.createObjectURL` の無効化も行うので、インライン script の state はレシピ 1 と同じ経路で読み込まれます。
|
|
2381
|
+
|
|
2382
|
+
### 3. 描画結果のスナップショット
|
|
2383
|
+
|
|
2384
|
+
`@wcstack/server` の [`renderToString()`](../server/README.ja.md) は描画済みマークアップを文字列で返します。保存したスナップショットと比較してください:
|
|
2385
|
+
|
|
2386
|
+
```ts
|
|
2387
|
+
import { expect, it } from "vitest";
|
|
2388
|
+
import { renderToString } from "@wcstack/server";
|
|
2389
|
+
|
|
2390
|
+
it("描画結果がスナップショットと一致する", async () => {
|
|
2391
|
+
const html = await renderToString(`
|
|
2392
|
+
<wcs-state json='{"items": ["apple", "banana"]}' enable-ssr></wcs-state>
|
|
2393
|
+
<ul><template data-wcs="for: items"><li data-wcs="textContent: items.*"></li></template></ul>
|
|
2394
|
+
`);
|
|
2395
|
+
expect(html).toMatchSnapshot();
|
|
2396
|
+
});
|
|
2397
|
+
```
|
|
2398
|
+
|
|
2241
2399
|
## TypeScript サポート
|
|
2242
2400
|
|
|
2243
2401
|
`defineState()` で状態オブジェクトをラップすると、メソッドや getter 内の `this` に型補完が効きます。ランタイムコストはゼロ(アイデンティティ関数)です。
|
|
@@ -2278,7 +2436,7 @@ bootstrapState();
|
|
|
2278
2436
|
|
|
2279
2437
|
| 属性 | 説明 |
|
|
2280
2438
|
|---|---|
|
|
2281
|
-
| `
|
|
2439
|
+
| `mount` | この state をルートツリーへ**ボリューム**として接ぎ木する静的ツリーパス(v2 — 撤去された `name` 属性の後継。ツリーは 1 root に 1 本) |
|
|
2282
2440
|
| `state` | `<script type="application/json">` 要素の ID |
|
|
2283
2441
|
| `src` | `.json` または `.js` ファイルの URL |
|
|
2284
2442
|
| `json` | インライン JSON 文字列 |
|
|
@@ -2288,7 +2446,6 @@ bootstrapState();
|
|
|
2288
2446
|
|
|
2289
2447
|
| プロパティ / メソッド | 説明 |
|
|
2290
2448
|
|---|---|
|
|
2291
|
-
| `name` | 状態名 |
|
|
2292
2449
|
| `initializePromise` | 状態の完全な初期化時に解決される Promise |
|
|
2293
2450
|
| `listPaths` | `for` ループで使用されるパスの Set |
|
|
2294
2451
|
| `getterPaths` | getter として定義されたパスの Set |
|