@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.md
CHANGED
|
@@ -46,13 +46,13 @@ In every existing framework, the **component** is the coupling point between UI
|
|
|
46
46
|
|-------|---------------|----------------------|
|
|
47
47
|
| **State** (`<wcs-state>`) | Data structure and business logic | Which DOM nodes are bound |
|
|
48
48
|
| **UI** (`data-wcs`) | Path strings and display intent | How state is stored or computed |
|
|
49
|
-
| **Components** (
|
|
49
|
+
| **Components** (`state: path`) | The mount table the host writes | The other component's internals |
|
|
50
50
|
|
|
51
51
|
Three levels of path contracts keep everything loosely coupled:
|
|
52
52
|
|
|
53
53
|
1. **UI ↔ State** — A `data-wcs="textContent: user.name"` attribute is the entire binding. No hooks, no selectors, no reactive primitives. The component's JavaScript doesn't contain a single line that references state.
|
|
54
54
|
|
|
55
|
-
2. **Component ↔ Component** —
|
|
55
|
+
2. **Component ↔ Component** — The host mounts a subtree onto each component (`<my-card data-wcs="state: user">`), and volumes graft extra modules onto the tree (`<wcs-state mount="i18n">`). Components never import or depend on each other; every connection is a path prefix on the single tree, nothing more.
|
|
56
56
|
|
|
57
57
|
3. **Loop context** — Inside a `for` loop, `*` acts as an abstract index. Bindings like `items.*.price` resolve to the current element automatically. The template doesn't know its concrete position — the wildcard is the contract.
|
|
58
58
|
|
|
@@ -222,19 +222,20 @@ Resolution order: `state` → `src` (.json / .js) → `json` → inner `<script>
|
|
|
222
222
|
|
|
223
223
|
> **Under a Content-Security-Policy:** form 5 (inline `<script type="module">`) is evaluated through a `blob:` URL and therefore requires `script-src blob:`. A page nonce does not cover it. If you enforce a strict CSP, use form 4 (`src="./state.js"`) instead — it needs no extra directive. See [docs/csp.md](../../docs/csp.md).
|
|
224
224
|
|
|
225
|
-
###
|
|
225
|
+
### Mounting Additional State (`mount=`)
|
|
226
226
|
|
|
227
|
-
|
|
227
|
+
There is **one state tree per root**. To split state across modules, mount a volume: its data grafts onto the root tree at the mount path, and bindings read it by prefix.
|
|
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
|
+
A volume may declare getters, `$watch`, `$listKeys`, `$updatedCallback`, and `$connectedCallback`/`$disconnectedCallback` — all relative to its mount path. Load order does not matter (a volume connected before the root is grafted when the root registers). Mount paths must be static (`*`, `$`, `#`, `@` are rejected).
|
|
237
|
+
|
|
238
|
+
> **Migrating from v1's named states:** `<wcs-state name="cart">` + `total@cart` becomes `<wcs-state mount="cart">` + `cart.total`. In v2 the `name` attribute fails fast and `@` in a path is a parse error, each with this exact guidance. Migration table: [docs/state-mount-design.md](../../docs/state-mount-design.md) §9.
|
|
238
239
|
|
|
239
240
|
## Updating State
|
|
240
241
|
|
|
@@ -287,7 +288,7 @@ this.items.sort((a, b) => a.id - b.id);
|
|
|
287
288
|
### `data-wcs` Attribute
|
|
288
289
|
|
|
289
290
|
```
|
|
290
|
-
property[#modifier]: path[
|
|
291
|
+
property[#modifier]: path[|filter[|filter(args)...]]
|
|
291
292
|
```
|
|
292
293
|
|
|
293
294
|
Multiple bindings separated by `;`:
|
|
@@ -301,7 +302,6 @@ Multiple bindings separated by `;`:
|
|
|
301
302
|
| `property` | DOM property to bind | `value`, `textContent`, `checked` |
|
|
302
303
|
| `#modifier` | Binding modifier | `#ro`, `#prevent`, `#stop`, `#onchange` |
|
|
303
304
|
| `path` | State property path | `count`, `user.name`, `users.*.name` |
|
|
304
|
-
| `@state` | Named state reference | `@cart`, `@user` |
|
|
305
305
|
| `\|filter` | Transform filter chain | `\|gt(0)`, `\|round\|locale` |
|
|
306
306
|
|
|
307
307
|
### Property Types
|
|
@@ -486,7 +486,7 @@ Runtime reads `customClass.wcBindable.properties + inputs` and expands each name
|
|
|
486
486
|
|
|
487
487
|
- Filters on the spread target (`...: target|filter`) are rejected.
|
|
488
488
|
- The right-hand path may contain `*` anywhere (e.g. `...: stores.*.fetch`).
|
|
489
|
-
-
|
|
489
|
+
- The right-hand side is a plain tree path (`...: fetchX` or `...: stores.*.fetch`).
|
|
490
490
|
- If the custom element class is not yet registered, expansion is deferred until `customElements.whenDefined(tag)` resolves — autoloader-style late registration is supported.
|
|
491
491
|
- Elements **without** a `wcBindable` declaration are rejected (write bindings explicitly). Spread requires the contract to know what to expand.
|
|
492
492
|
|
|
@@ -555,7 +555,6 @@ Inside a `for` loop, paths starting with `.` are expanded relative to the loop's
|
|
|
555
555
|
| `.name` | `users.*.name` | Property of the current element |
|
|
556
556
|
| `.` | `users.*` | The current element itself |
|
|
557
557
|
| `.name\|uc` | `users.*.name\|uc` | Filters are preserved |
|
|
558
|
-
| `.name@state` | `users.*.name@state` | State name is preserved |
|
|
559
558
|
|
|
560
559
|
For primitive arrays, `.` refers to the element value directly:
|
|
561
560
|
|
|
@@ -1203,7 +1202,7 @@ customElements.define("my-component", MyComponent);
|
|
|
1203
1202
|
|
|
1204
1203
|
### Component Definition (Light DOM)
|
|
1205
1204
|
|
|
1206
|
-
Light DOM components do not use Shadow DOM.
|
|
1205
|
+
Light DOM components do not use Shadow DOM. In v2 the form is identical to the Shadow form — the component's bindings are translated onto the host's tree at its mount point, so **no name and no `@` selectors are needed**, and the same component can sit on every row of a list:
|
|
1207
1206
|
|
|
1208
1207
|
```javascript
|
|
1209
1208
|
class MyLightComponent extends HTMLElement {
|
|
@@ -1211,30 +1210,20 @@ class MyLightComponent extends HTMLElement {
|
|
|
1211
1210
|
|
|
1212
1211
|
connectedCallback() {
|
|
1213
1212
|
this.innerHTML = `
|
|
1214
|
-
<wcs-state bind-component="state"
|
|
1215
|
-
<div data-wcs="text: message
|
|
1216
|
-
<input type="text" data-wcs="value: message
|
|
1213
|
+
<wcs-state bind-component="state"></wcs-state>
|
|
1214
|
+
<div data-wcs="text: message"></div>
|
|
1215
|
+
<input type="text" data-wcs="value: message" />
|
|
1217
1216
|
`;
|
|
1218
1217
|
}
|
|
1219
1218
|
}
|
|
1220
1219
|
customElements.define("my-light-component", MyLightComponent);
|
|
1221
1220
|
```
|
|
1222
1221
|
|
|
1223
|
-
- `name` attribute is **required** for Light DOM components (namespace is shared with the parent scope)
|
|
1224
|
-
- Bindings must explicitly reference the state name with `@my-light`
|
|
1225
1222
|
- `<wcs-state>` must be a direct child of the component element
|
|
1223
|
+
- The host **must wire it** (`<my-light-component data-wcs="state.message: user.name">` or `state: user`) — a plain, unwired Light DOM `bind-component` cannot exist in v2 (an independent tree cannot share the parent's root). It fails loudly with the migration guidance: attach a shadow root, or mount it from the host.
|
|
1226
1224
|
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
wired once the component's own state has registered its name
|
|
1230
|
-
|
|
1231
|
-
> **Note**: Light DOM shares its namespace with the parent scope, so **two instances carrying the same
|
|
1232
|
-
> `name` cannot live in one scope**. Use Shadow DOM for shapes that place a component on every row of
|
|
1233
|
-
> a list.
|
|
1234
|
-
>
|
|
1235
|
-
> Also, `State.getBindingsReady(root)` does not cover the component's scope — the same as the Shadow
|
|
1236
|
-
> DOM form, where the child lives in a different rootNode. Await the component's own `<wcs-state>`
|
|
1237
|
-
> initialization when you need to wait for its contents to render.
|
|
1225
|
+
> **Note**: `State.getBindingsReady(root)` covers mounted scopes once the mount record resolves; await
|
|
1226
|
+
> the component's own `<wcs-state>` initialization when you need its contents rendered.
|
|
1238
1227
|
|
|
1239
1228
|
### Host Usage
|
|
1240
1229
|
|
|
@@ -1255,6 +1244,54 @@ customElements.define("my-light-component", MyLightComponent);
|
|
|
1255
1244
|
- `data-wcs="state.message: user.name"` on the host element binds outer state paths to inner component state properties
|
|
1256
1245
|
- Changes propagate bidirectionally between the component and the outer state
|
|
1257
1246
|
|
|
1247
|
+
### Whole-object Mount (`state: path`)
|
|
1248
|
+
|
|
1249
|
+
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:
|
|
1250
|
+
|
|
1251
|
+
```html
|
|
1252
|
+
<!-- Host -->
|
|
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
|
+
// Component (Shadow DOM)
|
|
1259
|
+
class UserCard extends HTMLElement {
|
|
1260
|
+
state = {
|
|
1261
|
+
// a getter computed over the mount — `this.name` is the tree's `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` 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.
|
|
1281
|
+
- 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`).
|
|
1282
|
+
- 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.*`.
|
|
1283
|
+
- **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.
|
|
1284
|
+
- Mounting an array as the root (`state: rows` with `for` over it inside) is not supported; mount the row (`state: .`) or the object that holds the array (`state: group` with `for: children` inside). Both forms are contract-tested; mounts are the only way to extend the tree.
|
|
1285
|
+
|
|
1286
|
+
> The per-property form (`state.message: user.name`) keeps working — it is a partial mount on
|
|
1287
|
+
> the same machinery. R1 is strict for every mount form — a component that declares a default
|
|
1288
|
+
> for a mapped key (`state = { message: "" }` together with `state.message: ...`) keeps its
|
|
1289
|
+
> own key **private**, hiding the host value (a one-time `wcs/mount-own-key-shadow` warning
|
|
1290
|
+
> points at it). Drop the default to read the tree. The mounted `<wcs-state>` needs no `name`
|
|
1291
|
+
> in Light DOM, and `$getAll` / `$setAll` / `$resolve` / `$postUpdate` on `element.state`
|
|
1292
|
+
> (and on `this` inside getters/methods) speak the component's own vocabulary — paths are
|
|
1293
|
+
> translated onto the mount and the host row's indexes are prepended automatically.
|
|
1294
|
+
|
|
1258
1295
|
### Standalone Web Component Injection (`__e2e__/single-component`)
|
|
1259
1296
|
|
|
1260
1297
|
Even when a component is independent from outer host state, you can inject reactive state with `bind-component`.
|
|
@@ -1293,8 +1330,7 @@ customElements.define("my-component", MyComponent);
|
|
|
1293
1330
|
|
|
1294
1331
|
- `<wcs-state>` with `bind-component` must be a **direct child** of the component element (top-level)
|
|
1295
1332
|
- The parent element must be a **custom element** (tag name containing a hyphen)
|
|
1296
|
-
- Light DOM components
|
|
1297
|
-
- Light DOM bindings must reference the state name explicitly (e.g., `@my-light`)
|
|
1333
|
+
- Light DOM components must be wired from the host (the plain, unwired form was removed in v2)
|
|
1298
1334
|
|
|
1299
1335
|
### Loop with Components
|
|
1300
1336
|
|
|
@@ -1302,6 +1338,11 @@ customElements.define("my-component", MyComponent);
|
|
|
1302
1338
|
<template data-wcs="for: users">
|
|
1303
1339
|
<my-component data-wcs="state.message: .name"></my-component>
|
|
1304
1340
|
</template>
|
|
1341
|
+
|
|
1342
|
+
<!-- or mount the row itself: inside the component, `name` is `users.*.name` -->
|
|
1343
|
+
<template data-wcs="for: users">
|
|
1344
|
+
<user-row data-wcs="state: ."></user-row>
|
|
1345
|
+
</template>
|
|
1305
1346
|
```
|
|
1306
1347
|
|
|
1307
1348
|
### Rendering a List Inside the Component
|
|
@@ -1821,13 +1862,13 @@ Firing order is defined in three layers, and only the middle one is yours to ste
|
|
|
1821
1862
|
|
|
1822
1863
|
Key rules:
|
|
1823
1864
|
|
|
1824
|
-
- **
|
|
1865
|
+
- **Paths of the tree only** — a path may not contain `@` (the v1 name selector); such a declaration is rejected loudly.
|
|
1825
1866
|
- **Intermediate values are not observable** — a batch that goes `a → b → c` fires once with `cur = c`, `prev = a`, the same contract as binding updates.
|
|
1826
1867
|
- **Row-level diffs want `$listKeys`** — without it, assigning a whole array fires the row watch for *every* row with `prev === undefined`, because no row went through a path write. With `$listKeys` declared, the key match decomposes the assignment into per-field writes, so only changed rows fire and `prev` is a real scalar.
|
|
1827
1868
|
- **A headless row watch requires `$listKeys`** — this is the one place `$watch` is *not* headless on its own. Expanding `items` into `items.*.price` is driven by the list's `for` binding, and declaring a watch deliberately does not register the path as a list. So with neither a `for` binding nor `$listKeys`, assigning the array fires the row watch **zero** times. Add `$listKeys` (the key match writes each field by path, bypassing the expansion) or render the list. Scalar paths — including nested ones like `user.name` — are headless with no such condition.
|
|
1828
1869
|
- **Handler exceptions are isolated** — a throw is reported to the console and the remaining watches (and stream restarts) still run. This differs from `$connectedCallback` / `$updatedCallback`, which fail loudly.
|
|
1829
1870
|
- **Write chains are bounded** — a handler's writes form a new batch, so mutually-writing watches would loop forever; the chain is cut off after 32 links with a console error. Values and DOM are not rolled back.
|
|
1830
|
-
- **Not
|
|
1871
|
+
- **Not run on a mounted `bind-component` scope** — mounted components do not execute declaration surfaces: the `$watch` declaration is ignored with a one-time console warning that points to the root state (or a volume — `<wcs-state mount>` hosts `$watch` / `$listKeys` / `$updatedCallback`). This applies to `$streams` too. A plain (unwired Shadow) child owns an independent tree and can declare it.
|
|
1831
1872
|
- **SSR does not run watches** — handler side effects would otherwise execute on both server and client.
|
|
1832
1873
|
|
|
1833
1874
|
## Inputs and Attribute Mirror
|
|
@@ -2128,7 +2169,7 @@ The check **under-approximates**: it stays silent for anything it cannot decide
|
|
|
2128
2169
|
- A `null` / `undefined` parent (the "seed as `null`, assign later" shape)
|
|
2129
2170
|
- Row fields of a list that starts empty (the row shape is unknown)
|
|
2130
2171
|
- Sub-properties of an intermediate getter's return value
|
|
2131
|
-
-
|
|
2172
|
+
- Marker paths of mounted components (`#m…` — private keys and getters live on the mount overlay, not the raw state)
|
|
2132
2173
|
- Reserved `$` namespaces (`$command.*` and friends)
|
|
2133
2174
|
|
|
2134
2175
|
So **no warning is not a proof of correctness.** For exhaustive checking, run `npx @wcstack/lint <file>`.
|
|
@@ -2246,6 +2287,122 @@ the short answer is that translations belong on a path, not in a filter.
|
|
|
2246
2287
|
> `analyzeContract()` API reports drift between a live `static wcBindable` surface and
|
|
2247
2288
|
> a sidecar manifest for dev-time diagnostics.
|
|
2248
2289
|
|
|
2290
|
+
## Testing Your Page
|
|
2291
|
+
|
|
2292
|
+
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).
|
|
2293
|
+
|
|
2294
|
+
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.
|
|
2295
|
+
|
|
2296
|
+
### 1. vitest + happy-dom
|
|
2297
|
+
|
|
2298
|
+
`vitest.config.ts`:
|
|
2299
|
+
|
|
2300
|
+
```ts
|
|
2301
|
+
import { defineConfig } from "vitest/config";
|
|
2302
|
+
|
|
2303
|
+
export default defineConfig({
|
|
2304
|
+
test: { environment: "happy-dom", setupFiles: ["./tests/setup.ts"] },
|
|
2305
|
+
});
|
|
2306
|
+
```
|
|
2307
|
+
|
|
2308
|
+
`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):
|
|
2309
|
+
|
|
2310
|
+
```ts
|
|
2311
|
+
import { bootstrapState } from "@wcstack/state";
|
|
2312
|
+
|
|
2313
|
+
bootstrapState();
|
|
2314
|
+
URL.createObjectURL = undefined as any;
|
|
2315
|
+
```
|
|
2316
|
+
|
|
2317
|
+
A test:
|
|
2318
|
+
|
|
2319
|
+
```ts
|
|
2320
|
+
import { expect, it } from "vitest";
|
|
2321
|
+
import { getBindingsReady } from "@wcstack/state";
|
|
2322
|
+
|
|
2323
|
+
const settle = () => new Promise<void>((r) => setTimeout(r, 0));
|
|
2324
|
+
|
|
2325
|
+
it("renders, re-renders, and runs handlers", async () => {
|
|
2326
|
+
// 1. Mount the fragment under test
|
|
2327
|
+
document.body.innerHTML = `
|
|
2328
|
+
<wcs-state json='{"count": 1, "items": ["apple", "banana"]}'></wcs-state>
|
|
2329
|
+
<p id="count" data-wcs="textContent: count"></p>
|
|
2330
|
+
<ul id="items">
|
|
2331
|
+
<template data-wcs="for: items">
|
|
2332
|
+
<li data-wcs="textContent: items.*"></li>
|
|
2333
|
+
</template>
|
|
2334
|
+
</ul>
|
|
2335
|
+
`;
|
|
2336
|
+
|
|
2337
|
+
// 2. Wait for the state element, then for every binding under `document`
|
|
2338
|
+
const stateEl = document.querySelector("wcs-state") as any;
|
|
2339
|
+
await stateEl.connectedCallbackPromise;
|
|
2340
|
+
await getBindingsReady(document);
|
|
2341
|
+
|
|
2342
|
+
// 3. Assert the initial render
|
|
2343
|
+
expect(document.querySelector("#count")!.textContent).toBe("1");
|
|
2344
|
+
expect(document.querySelectorAll("#items li").length).toBe(2);
|
|
2345
|
+
|
|
2346
|
+
// 4. Write through a writable proxy — exactly what a handler does
|
|
2347
|
+
await stateEl.createStateAsync("writable", async (state: any) => {
|
|
2348
|
+
state.count = 42;
|
|
2349
|
+
state.items = [...state.items, "cherry"];
|
|
2350
|
+
});
|
|
2351
|
+
await settle();
|
|
2352
|
+
|
|
2353
|
+
// 5. Assert the re-render
|
|
2354
|
+
expect(document.querySelector("#count")!.textContent).toBe("42");
|
|
2355
|
+
expect(document.querySelectorAll("#items li").length).toBe(3);
|
|
2356
|
+
});
|
|
2357
|
+
```
|
|
2358
|
+
|
|
2359
|
+
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()`.
|
|
2360
|
+
|
|
2361
|
+
- `getBindingsReady(root)` resolves once every binding under `root` (a `document` or a shadow root) is built, and rejects if binding initialization fails (v1.26+).
|
|
2362
|
+
- Updates settle on the microtask queue; a single `setTimeout(0)` after a write is enough.
|
|
2363
|
+
- `state.items = [...state.items, "cherry"]` is the reactive form — `state.items.push()` is not observed (same rule as in handlers).
|
|
2364
|
+
- 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.
|
|
2365
|
+
- 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.
|
|
2366
|
+
|
|
2367
|
+
### 2. Bare Node (no vitest)
|
|
2368
|
+
|
|
2369
|
+
`@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:
|
|
2370
|
+
|
|
2371
|
+
```js
|
|
2372
|
+
import { Window } from "happy-dom";
|
|
2373
|
+
import { installGlobals } from "@wcstack/server";
|
|
2374
|
+
|
|
2375
|
+
const window = new Window({ url: "http://localhost/" });
|
|
2376
|
+
const restore = installGlobals(window); // document, customElements, HTMLElement, ... (GLOBALS_KEYS)
|
|
2377
|
+
try {
|
|
2378
|
+
const { bootstrapState, getBindingsReady } = await import("@wcstack/state");
|
|
2379
|
+
bootstrapState();
|
|
2380
|
+
// ... the same mount / await / assert steps as recipe 1
|
|
2381
|
+
} finally {
|
|
2382
|
+
restore();
|
|
2383
|
+
await window.happyDOM.close();
|
|
2384
|
+
}
|
|
2385
|
+
```
|
|
2386
|
+
|
|
2387
|
+
`installGlobals` also disables `URL.createObjectURL` for you, so inline-script state loads the same way as in recipe 1.
|
|
2388
|
+
|
|
2389
|
+
### 3. Snapshot the rendered HTML
|
|
2390
|
+
|
|
2391
|
+
[`renderToString()`](../server/README.md) from `@wcstack/server` returns the fully rendered markup as a string; compare it against a stored snapshot:
|
|
2392
|
+
|
|
2393
|
+
```ts
|
|
2394
|
+
import { expect, it } from "vitest";
|
|
2395
|
+
import { renderToString } from "@wcstack/server";
|
|
2396
|
+
|
|
2397
|
+
it("matches the rendered snapshot", async () => {
|
|
2398
|
+
const html = await renderToString(`
|
|
2399
|
+
<wcs-state json='{"items": ["apple", "banana"]}' enable-ssr></wcs-state>
|
|
2400
|
+
<ul><template data-wcs="for: items"><li data-wcs="textContent: items.*"></li></template></ul>
|
|
2401
|
+
`);
|
|
2402
|
+
expect(html).toMatchSnapshot();
|
|
2403
|
+
});
|
|
2404
|
+
```
|
|
2405
|
+
|
|
2249
2406
|
## TypeScript Support
|
|
2250
2407
|
|
|
2251
2408
|
`defineState()` wraps your state object and provides type-safe `this` inside methods and getters — with zero runtime cost (identity function).
|
|
@@ -2286,7 +2443,7 @@ bootstrapState();
|
|
|
2286
2443
|
|
|
2287
2444
|
| Attribute | Description |
|
|
2288
2445
|
|---|---|
|
|
2289
|
-
| `
|
|
2446
|
+
| `mount` | Static tree path to graft this state onto the root tree as a **volume** (v2 — replaces the removed `name` attribute; one state tree per root) |
|
|
2290
2447
|
| `state` | ID of a `<script type="application/json">` element |
|
|
2291
2448
|
| `src` | URL to `.json` or `.js` file |
|
|
2292
2449
|
| `json` | Inline JSON string |
|
|
@@ -2296,7 +2453,6 @@ bootstrapState();
|
|
|
2296
2453
|
|
|
2297
2454
|
| Property / Method | Description |
|
|
2298
2455
|
|---|---|
|
|
2299
|
-
| `name` | State name |
|
|
2300
2456
|
| `initializePromise` | Resolves when state is fully initialized |
|
|
2301
2457
|
| `listPaths` | Set of paths used in `for` loops |
|
|
2302
2458
|
| `getterPaths` | Set of paths defined as getters |
|