@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.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 —
|
|
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)`
|
|
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'` |
|
|
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).
|