@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.md CHANGED
@@ -236,6 +236,8 @@ Multiple state elements can coexist with the `name` attribute. Bindings referenc
236
236
 
237
237
  Default name is `"default"` (no `@` needed).
238
238
 
239
+ > **Deprecated — removed in v2.** The `name` attribute and the `@name` selector are a second axis next to the path (a per-rootNode registry that does not cross shadow boundaries). v2 replaces them with **mounts**: `<wcs-state mount="cart">` grafts the state onto the root tree, and bindings read it as `cart.total`. Nothing changes in 1.x; the linter reports the sites as `wcs/named-state-deprecated` (warning) and the runtime warns only under `config.debug`. Migration table: [docs/state-mount-design.md](../../docs/state-mount-design.md) §9.
240
+
239
241
  ## Updating State
240
242
 
241
243
  In `@wcstack/state`, every piece of state has a **path** — like `count`, `user.name`, or `items`. To update state reactively, **assign to the path**:
@@ -769,19 +771,20 @@ export default {
769
771
  + this["regions.*.prefectures.*.cities.*.name"];
770
772
  },
771
773
 
772
- // Prefecture level — aggregate from cities
774
+ // Prefecture level — aggregate from cities. `indexes` omitted: it defaults to
775
+ // the loop context ([$1, $2]), so only this prefecture's cities are summed
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
- // Region level — aggregate from prefectures
781
+ // Region level — aggregate from prefectures (context [$1] narrows to this region)
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
- // Top level — aggregate from regions
787
+ // Top level — no loop context; [] means "every match"
785
788
  get totalPopulation() {
786
789
  return this.$getAll("regions.*.totalPopulation", [])
787
790
  .reduce((a, b) => a + b, 0);
@@ -943,6 +946,7 @@ Inside state objects (getters / methods), the following APIs are available via `
943
946
  | API | Description |
944
947
  |---|---|
945
948
  | `this.$getAll(path, indexes?)` | Get all values matching a wildcard path |
949
+ | `this.$setAll(path, indexes, value, options?)` | Write to every address matching a wildcard path |
946
950
  | `this.$resolve(path, indexes, value?)` | Resolve a wildcard path with specific indexes |
947
951
  | `this.$postUpdate(path)` | Manually trigger update notification for a path |
948
952
  | `this.$trackDependency(path)` | Manually register a dependency for cache invalidation |
@@ -968,6 +972,66 @@ export default {
968
972
  };
969
973
  ```
970
974
 
975
+ `indexes` is a **prefix** over the path's wildcards: missing levels expand fully, and `[]` always means "every match". When `indexes` is **omitted**, it defaults to the enclosing loop context (`[$1, $2, ...]`), applied to the wildcard levels the path shares with that context:
976
+
977
+ ```javascript
978
+ export default {
979
+ regions: [ /* { prefectures: [ { population: … }, … ] } */ ],
980
+ // Loop context [$1] — omission narrows to the current region
981
+ get "regions.*.total"() {
982
+ return this.$getAll("regions.*.prefectures.*.population").reduce((a, b) => a + b, 0);
983
+ },
984
+ // No loop context — omission expands everything (same as [])
985
+ get grandTotal() {
986
+ return this.$getAll("regions.*.total").reduce((a, b) => a + b, 0);
987
+ }
988
+ };
989
+ ```
990
+
991
+ Context levels deeper than the path needs are dropped (a `[$1, $2]` context narrows a one-wildcard path by `[$1]`). But if the path shares **no** wildcard level with a context that does hold loop indexes — say `$getAll("users.*.name")` inside a `regions.*` getter — `$getAll` **throws** instead of silently reading every user: the context indexes belong to a different list, and neither reusing nor ignoring them is what the author meant. Pass indexes explicitly there (`[]` for every match).
992
+
993
+ #### `$setAll` — Update Every Array Element In Place
994
+
995
+ `$setAll` is the write-side counterpart of `$getAll`: it writes to every address a wildcard path matches. The point is not brevity but **keeping the array itself**. Rebuilding it (`this.users = this.users.map(...)`) throws away the list indexes, the per-row getter caches, and the render diff; `$setAll` decomposes into in-place per-row writes instead, so the list identity survives.
996
+
997
+ ```javascript
998
+ export default {
999
+ users: [{ selected: false }, { selected: false }],
1000
+
1001
+ toggleAll(e) {
1002
+ this.$setAll("users.*.selected", [], e.target.checked); // broadcast
1003
+ },
1004
+ invertAll() {
1005
+ this.$setAll("users.*.selected", [], cur => !cur); // mapper
1006
+ },
1007
+ rankTopThree() {
1008
+ // `undefined` skips that address — "leave this row alone"
1009
+ this.$setAll("users.*.score", [], (cur, i) => i < 3 ? cur * 2 : undefined);
1010
+ }
1011
+ };
1012
+ ```
1013
+
1014
+ Three forms, and the third one has to be asked for explicitly:
1015
+
1016
+ | Third argument | Meaning |
1017
+ |---|---|
1018
+ | a function | **mapper** — called as `(current, ...indexes)` per matched address |
1019
+ | anything else | **broadcast** — the same value is written everywhere, arrays included |
1020
+ | an array **plus** `{ spread: true }` | **spread** — one entry handed to each matched address, in match order |
1021
+
1022
+ Arrays broadcast by default because the target property may itself be array-valued — `$setAll("users.*.tags", [], ["admin"])` would otherwise be ambiguous. Opting into `{ spread: true }` removes the guesswork, and a length that does not equal the match count throws rather than silently misaligning.
1023
+
1024
+ `indexes` works exactly as in `$getAll` — a **prefix**, where missing levels mean "expand all of them" — but it is **required**. Writes get no implicit loop context, so inside a `for` template `this.$setAll("users.*.selected", [], true)` still means *every* user, never the current row.
1025
+
1026
+ ```javascript
1027
+ this.$setAll("matrix.*.*", [0], 0); // row 0 only, every column
1028
+ this.$setAll("users.*", [], rows, { spread: true }); // replace each row, keep the array
1029
+ ```
1030
+
1031
+ `undefined` is never written — it means "skip this address" in all three forms, which keeps a mapper that forgets to `return` from wiping every row. Use `null` to clear. The return value is the number of addresses actually written.
1032
+
1033
+ One thing `$setAll` is not: a shortcut for the dependency walk. Rendering still coalesces into a single batch, but each write is enqueued individually, so the cost matches the hand-written loop it replaces. What it buys you is the preserved list, not fewer cycles.
1034
+
971
1035
  #### `$resolve` — Access by Explicit Index
972
1036
 
973
1037
  `$resolve` reads or writes a value at a specific wildcard index:
@@ -1193,6 +1257,47 @@ customElements.define("my-light-component", MyLightComponent);
1193
1257
  - `data-wcs="state.message: user.name"` on the host element binds outer state paths to inner component state properties
1194
1258
  - Changes propagate bidirectionally between the component and the outer state
1195
1259
 
1260
+ ### Whole-object Mount (`state: path`)
1261
+
1262
+ Instead of wiring the component's state property by property, the host can mount a **whole subtree** of its state as the component's root. Inside the component every path is then relative to the mount point:
1263
+
1264
+ ```html
1265
+ <!-- Host -->
1266
+ <wcs-state json='{"user":{"name":"Alice","email":"alice@example.com"},"theme":{"mode":"light"}}'></wcs-state>
1267
+ <user-card data-wcs="state: user"></user-card>
1268
+ ```
1269
+
1270
+ ```javascript
1271
+ // Component (Shadow DOM)
1272
+ class UserCard extends HTMLElement {
1273
+ state = {
1274
+ // a getter computed over the mount — `this.name` is the tree's `user.name`
1275
+ get display() { return `${this.name} <${this.email}>`; },
1276
+ };
1277
+ constructor() {
1278
+ super();
1279
+ this.attachShadow({ mode: "open" });
1280
+ }
1281
+ connectedCallback() {
1282
+ this.shadowRoot.innerHTML = `
1283
+ <wcs-state bind-component="state"></wcs-state>
1284
+ <span data-wcs="textContent: name"></span>
1285
+ <span data-wcs="textContent: display"></span>
1286
+ <input data-wcs="value: name">
1287
+ `;
1288
+ }
1289
+ }
1290
+ customElements.define("user-card", UserCard);
1291
+ ```
1292
+
1293
+ - `state: user` mounts the component's root at the tree path `user`: `name` inside the component **is** `user.name`. Reads, writes (`value: name`, `this.state.name = ...`), getters and `for:` all resolve against the tree; the host's `this.user = {...}` replacement and `this["user.name"] = ...` writes both reach the component.
1294
+ - A partial mount can sit next to it: `state: user; state.theme: theme` mounts `theme` as a second entry point (longest prefix wins, so `theme.mode` inside the component reads the tree's `theme.mode`).
1295
+ - In a loop, mount **the row itself**: `<template data-wcs="for: users"><user-row data-wcs="state: ."></user-row></template>`. Inside the row component `name` is `users.*.name`, and its own `for: tags` runs over `users.*.tags.*`.
1296
+ - **Own keys are private** (rule R1 in [docs/state-mount-design.md](../../docs/state-mount-design.md) §4-3): a data key the component declares itself (`state = { mode: "view" }`) belongs to that element and is never written to the tree. If it hides a key that exists at the mount point (`state = { name: "" }` mounted over `user.name`), the runtime warns once (`wcs/mount-own-key-shadow`) — remove the default to read the tree, or rename it to keep it private.
1297
+ - Mounting an array as the root (`state: rows` with `for` over it inside) is not supported in 1.x; mount the row (`state: .`) or the object that holds the array (`state: group` with `for: children` inside). Both forms are contract-tested and carry over unchanged to v2, where mounts become the only way to extend the tree.
1298
+
1299
+ > The per-property form (`state.message: user.name`) keeps working. A component that declares a default for a mapped key (`state = { message: "" }` together with `state.message: ...`) gets a one-time warning in 1.x: today the host value wins, in v2 the own key becomes private and would hide it — drop the default.
1300
+
1196
1301
  ### Standalone Web Component Injection (`__e2e__/single-component`)
1197
1302
 
1198
1303
  Even when a component is independent from outer host state, you can inject reactive state with `bind-component`.
@@ -1240,6 +1345,11 @@ customElements.define("my-component", MyComponent);
1240
1345
  <template data-wcs="for: users">
1241
1346
  <my-component data-wcs="state.message: .name"></my-component>
1242
1347
  </template>
1348
+
1349
+ <!-- or mount the row itself: inside the component, `name` is `users.*.name` -->
1350
+ <template data-wcs="for: users">
1351
+ <user-row data-wcs="state: ."></user-row>
1352
+ </template>
1243
1353
  ```
1244
1354
 
1245
1355
  ### Rendering a List Inside the Component
@@ -1755,6 +1865,8 @@ Firing order is defined in three layers, and only the middle one is yours to ste
1755
1865
  | Between handlers | declaration order in `$watch` | **reorder the declarations** |
1756
1866
  | Between rows of one path | ascending `indexes` | fixed |
1757
1867
 
1868
+ **The one thing that moves the mechanism layer** is a `<wcs-view-transition>` that accepts the `state` participant. Binding application — and with it `$updatedCallback` — then lands on a frame, while `$watch` and the `$streams` restart stay on the microtask the drain was queued on, because they consume state addresses and not the DOM. For as long as the tag is present the order is `$watch` → `$streams` restart → `$updatedCallback`. Nothing else on the page reorders this layer; see [docs/timing-and-firing-contract.md](https://github.com/wcstack/wcstack/blob/main/docs/timing-and-firing-contract.md) §4.3.
1869
+
1758
1870
  Key rules:
1759
1871
 
1760
1872
  - **Only its own state** — a path may not carry `@stateName`; watching another state element is rejected at declaration time.
@@ -2016,6 +2128,31 @@ All hooks except `$disconnectedCallback` support `async` — you can use `async/
2016
2128
  - `$updatedCallback(paths, indexesListByPath)` receives the paths whose live bindings were applied in that drain. Unbound state writes do not invoke it or appear in `paths`. For wildcard updates, `indexesListByPath` contains the updated index sets. Can be `async`, but the return value is not awaited
2017
2129
  - In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
2018
2130
 
2131
+ ## Transition animations
2132
+
2133
+ Enter animations need nothing from this package — a new `for` row and a mounting `if` branch are newly inserted elements, so plain CSS covers them:
2134
+
2135
+ ```css
2136
+ li {
2137
+ transition: opacity 0.2s, transform 0.2s;
2138
+ @starting-style { opacity: 0; transform: translateY(-4px); }
2139
+ }
2140
+ ```
2141
+
2142
+ **Leaving** and **moving** cannot be reached that way: removed rows are detached synchronously, and a reorder has no intermediate state. Adding [`@wcstack/view-transition`](https://github.com/wcstack/wcstack/tree/main/packages/view-transition) makes the drain apply its DOM changes inside a View Transition, where the browser snapshots the old state for you:
2143
+
2144
+ ```html
2145
+ <script type="module" src="https://esm.run/@wcstack/view-transition/auto"></script>
2146
+ <wcs-view-transition naming="auto"></wcs-view-transition>
2147
+ ```
2148
+
2149
+ Two consequences to know while that tag accepts the `state` participant:
2150
+
2151
+ - The drain lands on a frame instead of a microtask, so code that writes state and then reads the DOM after `await Promise.resolve()` must wait for the transition. `$updatedCallback` still fires immediately after the bindings are applied — its *position* is unchanged, but it moves a frame later along with them.
2152
+ - Because `$watch` and the `$streams` restart stay on the original microtask, they now run **before** `$updatedCallback` instead of after it.
2153
+
2154
+ Only a batch that actually has bindings to apply is handed to the tag, so a write to a headless path never starts a transition. Without the tag the drain is exactly what it was. See [docs/timing-and-firing-contract.md](https://github.com/wcstack/wcstack/blob/main/docs/timing-and-firing-contract.md) §4.3.
2155
+
2019
2156
  ## Diagnostics and failure handling
2020
2157
 
2021
2158
  ### Wiring to a path that does not exist is reported
@@ -2050,7 +2187,7 @@ Anything that follows mechanically from the path string is reported at runtime a
2050
2187
 
2051
2188
  | Diagnostic | What it checks | Fix |
2052
2189
  |---|---|---|
2053
- | `wcs/index-arity` | `$resolve(path, indexes)` must match the `*` count **exactly**; `$getAll(path, indexes)` has it as an **upper bound** (fewer is a legitimate prefix meaning "expand the rest") | Match the count |
2190
+ | `wcs/index-arity` | `$resolve(path, indexes)` must match the `*` count **exactly**; `$getAll(path, indexes)` / `$setAll(path, indexes, …)` have it as an **upper bound** (fewer is a legitimate prefix meaning "expand the rest") | Match the count |
2054
2191
  | `wcs/wildcard-rank` | The path's `*` count (and the N in `$N`) must not exceed the enclosing `for` nesting | Add a `for`, or name the row with `$resolve(path, indexes)` |
2055
2192
  | `wcs/getter-cycle` | Path getters must not form a dependency cycle | Break the cycle |
2056
2193
 
@@ -2107,13 +2244,43 @@ All options with defaults:
2107
2244
  |---|---|---|
2108
2245
  | `bindAttributeName` | `'data-wcs'` | Binding attribute name |
2109
2246
  | `tagNames.state` | `'wcs-state'` | State element tag name |
2110
- | `locale` | `'en'` | Default locale for filters |
2247
+ | `locale` | `<html lang>`, else `'en'` | Locale for the locale-dependent filters (`locale` / `date` / `time` / `datetime`) — see [Locale](#locale) |
2111
2248
  | `debug` | `false` | Debug mode |
2112
2249
  | `enableMustache` | `true` | Enable `{{ }}` syntax |
2113
2250
  | `enableDirectionalInitialSync` | `true` | Direction-aware binding authority (`#init=` / `#sync=` binding modifiers) — see [Binding Authority](#binding-authority-init--sync). Default on; set `false` to opt out |
2114
2251
  | `enablePropagationContext` | `true` | Causal propagation tracking across bindings (echo/diamond loop prevention). Default on; set `false` to opt out |
2115
2252
  | `enableContractAnalyzer` | `false` | Opt-in dev-time contract analyzer (exposes `analyzeContract`) |
2116
2253
 
2254
+ ### Locale
2255
+
2256
+ Four filters format by locale — `locale`, `date`, `time`, `datetime`. They read
2257
+ `config.locale`, which **defaults to `<html lang>`**:
2258
+
2259
+ ```html
2260
+ <html lang="ja-JP">
2261
+ <script type="module" src="https://esm.run/@wcstack/state/auto"></script>
2262
+ ```
2263
+
2264
+ Nothing else is needed; `<html lang>` is the standard place to record a page's
2265
+ language, and making it the default keeps one source of truth. It also means the
2266
+ CDN one-liner can set the locale at all — `auto` calls `bootstrapState()` with no
2267
+ arguments, so before this there was no way in. An explicit
2268
+ `bootstrapState({ locale })` still wins, and an invalid BCP-47 tag is reported
2269
+ and ignored rather than left to throw inside `Intl`.
2270
+
2271
+ **Changing `config.locale` later does not re-render anything.** It is a global
2272
+ setting, not state, so it is not part of the dependency graph. The filters do
2273
+ read it on every application rather than capturing it when the binding is built,
2274
+ which means a binding that re-renders for its own reasons will pick up the new
2275
+ value — enough to recover from a mis-ordered startup, not enough to switch a
2276
+ page's language. Set the language before the page renders: writing `<html lang>`
2277
+ in the markup, or from a synchronous `<head>` script, does that structurally.
2278
+
2279
+ Per-call overrides stay available and are fixed at bind time, since they are part
2280
+ of the binding expression: `price|locale(fr-FR)`. For a page that switches
2281
+ language without reloading, see [docs/i18n-design.md](../../docs/i18n-design.md) —
2282
+ the short answer is that translations belong on a path, not in a filter.
2283
+
2117
2284
  > These three are **architecture-hardening** features; their normative reference is
2118
2285
  > `docs/architecture-hardening/`. `enablePropagationContext` defaults **on** — its
2119
2286
  > write-path cost is near-zero for one-way bindings (only echo-capable two-way
@@ -2127,6 +2294,122 @@ All options with defaults:
2127
2294
  > `analyzeContract()` API reports drift between a live `static wcBindable` surface and
2128
2295
  > a sidecar manifest for dev-time diagnostics.
2129
2296
 
2297
+ ## Testing Your Page
2298
+
2299
+ A page built on `<wcs-state>` is plain DOM, so it can be tested headlessly with [happy-dom](https://github.com/capricorn86/happy-dom) — no browser, no build step, no test-only API. Three recipes follow; every one of them runs as written (recipe 1 is pinned by [`__tests__/readme.testingRecipe.test.ts`](__tests__/readme.testingRecipe.test.ts), which executes the same lines).
2300
+
2301
+ Want it as one import? [`@wcstack/testing`](../testing/README.md) packages recipe 1 as `mount()` / `settle()` / `fire()` (and waits for `<wcs-router>` too). The bare recipes below stay valid without it.
2302
+
2303
+ ### 1. vitest + happy-dom
2304
+
2305
+ `vitest.config.ts`:
2306
+
2307
+ ```ts
2308
+ import { defineConfig } from "vitest/config";
2309
+
2310
+ export default defineConfig({
2311
+ test: { environment: "happy-dom", setupFiles: ["./tests/setup.ts"] },
2312
+ });
2313
+ ```
2314
+
2315
+ `tests/setup.ts` — register the elements once, and route inline `<script type="module">` state through the `data:` URL loader (Node cannot import `blob:` URLs; without this line an inline-script state never finishes loading):
2316
+
2317
+ ```ts
2318
+ import { bootstrapState } from "@wcstack/state";
2319
+
2320
+ bootstrapState();
2321
+ URL.createObjectURL = undefined as any;
2322
+ ```
2323
+
2324
+ A test:
2325
+
2326
+ ```ts
2327
+ import { expect, it } from "vitest";
2328
+ import { getBindingsReady } from "@wcstack/state";
2329
+
2330
+ const settle = () => new Promise<void>((r) => setTimeout(r, 0));
2331
+
2332
+ it("renders, re-renders, and runs handlers", async () => {
2333
+ // 1. Mount the fragment under test
2334
+ document.body.innerHTML = `
2335
+ <wcs-state json='{"count": 1, "items": ["apple", "banana"]}'></wcs-state>
2336
+ <p id="count" data-wcs="textContent: count"></p>
2337
+ <ul id="items">
2338
+ <template data-wcs="for: items">
2339
+ <li data-wcs="textContent: items.*"></li>
2340
+ </template>
2341
+ </ul>
2342
+ `;
2343
+
2344
+ // 2. Wait for the state element, then for every binding under `document`
2345
+ const stateEl = document.querySelector("wcs-state") as any;
2346
+ await stateEl.connectedCallbackPromise;
2347
+ await getBindingsReady(document);
2348
+
2349
+ // 3. Assert the initial render
2350
+ expect(document.querySelector("#count")!.textContent).toBe("1");
2351
+ expect(document.querySelectorAll("#items li").length).toBe(2);
2352
+
2353
+ // 4. Write through a writable proxy — exactly what a handler does
2354
+ await stateEl.createStateAsync("writable", async (state: any) => {
2355
+ state.count = 42;
2356
+ state.items = [...state.items, "cherry"];
2357
+ });
2358
+ await settle();
2359
+
2360
+ // 5. Assert the re-render
2361
+ expect(document.querySelector("#count")!.textContent).toBe("42");
2362
+ expect(document.querySelectorAll("#items li").length).toBe(3);
2363
+ });
2364
+ ```
2365
+
2366
+ To drive the page the way a user does, keep the state inline (methods included) and dispatch DOM events; a `data-wcs="onclick: up"` handler runs on `button.click()`, and the DOM reflects the write after one `settle()`.
2367
+
2368
+ - `getBindingsReady(root)` resolves once every binding under `root` (a `document` or a shadow root) is built, and rejects if binding initialization fails (v1.26+).
2369
+ - Updates settle on the microtask queue; a single `setTimeout(0)` after a write is enough.
2370
+ - `state.items = [...state.items, "cherry"]` is the reactive form — `state.items.push()` is not observed (same rule as in handlers).
2371
+ - Under happy-dom, `customElements.define` upgrades existing nodes by **replacing** them; "a value reaches the same node after a late define" cannot be asserted headlessly. Event timing differences between happy-dom and real browsers are the other blind spot — keep one browser e2e (Playwright) for those.
2372
+ - happy-dom's `textContent` setter turns a numeric `0` into an empty string (browsers render `"0"`), so a `textContent: count` binding reads `""` at zero in this recipe. Assert on the state value, or use `@wcstack/testing`, whose `mount()` shims the setter.
2373
+
2374
+ ### 2. Bare Node (no vitest)
2375
+
2376
+ `@wcstack/server` already exports the globals swap it uses for SSR; reuse it. **Import `@wcstack/state` dynamically after `installGlobals`** — the element classes pick their base class when the module is evaluated, so a static import at the top of the file registers elements that happy-dom cannot construct:
2377
+
2378
+ ```js
2379
+ import { Window } from "happy-dom";
2380
+ import { installGlobals } from "@wcstack/server";
2381
+
2382
+ const window = new Window({ url: "http://localhost/" });
2383
+ const restore = installGlobals(window); // document, customElements, HTMLElement, ... (GLOBALS_KEYS)
2384
+ try {
2385
+ const { bootstrapState, getBindingsReady } = await import("@wcstack/state");
2386
+ bootstrapState();
2387
+ // ... the same mount / await / assert steps as recipe 1
2388
+ } finally {
2389
+ restore();
2390
+ await window.happyDOM.close();
2391
+ }
2392
+ ```
2393
+
2394
+ `installGlobals` also disables `URL.createObjectURL` for you, so inline-script state loads the same way as in recipe 1.
2395
+
2396
+ ### 3. Snapshot the rendered HTML
2397
+
2398
+ [`renderToString()`](../server/README.md) from `@wcstack/server` returns the fully rendered markup as a string; compare it against a stored snapshot:
2399
+
2400
+ ```ts
2401
+ import { expect, it } from "vitest";
2402
+ import { renderToString } from "@wcstack/server";
2403
+
2404
+ it("matches the rendered snapshot", async () => {
2405
+ const html = await renderToString(`
2406
+ <wcs-state json='{"items": ["apple", "banana"]}' enable-ssr></wcs-state>
2407
+ <ul><template data-wcs="for: items"><li data-wcs="textContent: items.*"></li></template></ul>
2408
+ `);
2409
+ expect(html).toMatchSnapshot();
2410
+ });
2411
+ ```
2412
+
2130
2413
  ## TypeScript Support
2131
2414
 
2132
2415
  `defineState()` wraps your state object and provides type-safe `this` inside methods and getters — with zero runtime cost (identity function).