@wcstack/state 3.1.0 → 3.3.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 +128 -90
- package/README.md +129 -91
- package/dist/auto.min.js +1 -1
- package/dist/auto.min.js.map +1 -1
- package/dist/define.d.ts +11 -7
- package/dist/index.d.ts +58 -15
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/manifest.d.ts +39 -5
- package/dist/manifest.esm.js +315 -253
- package/dist/parser.d.ts +16 -2
- package/dist/parser.esm.js +223 -50
- package/dist/split/chunks/binder.js +1 -1
- package/dist/split/chunks/binder.js.map +1 -1
- package/dist/split/chunks/checkDependency.js +1 -1
- package/dist/split/chunks/declarationAliases.js +2 -0
- package/dist/split/chunks/declarationAliases.js.map +1 -0
- package/dist/split/chunks/declarationHooks.js +2 -0
- package/dist/split/chunks/declarationHooks.js.map +1 -0
- package/dist/split/chunks/featureBridge.js +2 -0
- package/dist/split/chunks/featureBridge.js.map +1 -0
- package/dist/split/chunks/filterRegistry.js +1 -1
- package/dist/split/chunks/filterRegistry.js.map +1 -1
- package/dist/split/chunks/getAllPropertyDescriptors.js +1 -1
- package/dist/split/chunks/getAllPropertyDescriptors.js.map +1 -1
- package/dist/split/chunks/lifecycleHooks.js +1 -1
- package/dist/split/chunks/pathDiagnostics.js +1 -1
- package/dist/split/chunks/pathDiagnostics.js.map +1 -1
- package/dist/split/chunks/registerComponents.js +1 -1
- package/dist/split/chunks/registerComponents.js.map +1 -1
- package/dist/split/chunks/setAll.js +1 -1
- package/dist/split/chunks/setAll.js.map +1 -1
- package/dist/split/chunks/version.js +1 -1
- package/dist/split/core.d.ts +21 -9
- package/dist/split/core.js +1 -1
- package/dist/split/core.js.map +1 -1
- package/dist/split/features/devtools.js +1 -1
- package/dist/split/features/devtools.js.map +1 -1
- package/dist/split/features/diagnostics.js +1 -1
- package/dist/split/features/diagnostics.js.map +1 -1
- package/dist/split/features/formats.js +1 -1
- package/dist/split/features/formats.js.map +1 -1
- package/dist/split/features/recursion.js +1 -1
- package/dist/split/features/recursion.js.map +1 -1
- package/dist/split/features/scopes.js +1 -1
- package/dist/split/features/scopes.js.map +1 -1
- package/dist/split/features/ssr.js +1 -1
- package/dist/split/features/ssr.js.map +1 -1
- package/dist/split/features/temporal.js +1 -1
- package/dist/split/features/temporal.js.map +1 -1
- package/dist/wcs-manifest.json +98 -44
- package/package.json +1 -1
- package/dist/split/chunks/mount.js +0 -2
- package/dist/split/chunks/mount.js.map +0 -1
- package/dist/split/chunks/watchRuntime.js +0 -2
- package/dist/split/chunks/watchRuntime.js.map +0 -1
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ This separates UI and state with **no JavaScript intermediary**. You can:
|
|
|
88
88
|
|
|
89
89
|
- Redesign the UI without touching state logic — as far as the logic does not hang off what is rendered: a live binding is one of the three [demand roots](#demand-roots--what-makes-a-getter-run), so an element you think of as display-only can be the page's only subscription
|
|
90
90
|
- Refactor state structure and only update path strings
|
|
91
|
-
- Read the HTML and know every binding; the dependencies that are not in the HTML (`$watch`, `$
|
|
91
|
+
- Read the HTML and know every binding; the dependencies that are not in the HTML (`$watch`, `$stream`, `$scan`) are all declared in one place, the state
|
|
92
92
|
|
|
93
93
|
The path contract works like a URL in a REST API — a simple string that both sides agree on, with no shared code between them. It's the natural result of building on HTML's declarative nature rather than inventing a template language on top of JavaScript.
|
|
94
94
|
|
|
@@ -132,12 +132,12 @@ Every row is a section of this README. Unless it appears under [Where the neighb
|
|
|
132
132
|
| **Aggregation and bulk write** | `$getAll` / `$setAll` / `$resolve` read and write across `items.*.price` without rebuilding the array | [Proxy APIs](#proxy-apis) |
|
|
133
133
|
| **Recursive paths** | `$recursion` declares where a tree's shape repeats; one `**` getter covers every depth | [Recursive paths](#recursive-paths-recursion) |
|
|
134
134
|
| **Reactivity** | An ES Proxy tracks reads per address, caches per address, invalidates in dependency order and batches DOM writes on a microtask | [Updating state](#updating-state) · [Dependency tracking boundaries](#dependency-tracking-boundaries) |
|
|
135
|
-
| **What makes a getter run** | Getters are lazy. Demand comes from a live binding, a `$watch` or a `$
|
|
135
|
+
| **What makes a getter run** | Getters are lazy. Demand comes from a live binding, a `$watch` or a `$stream` `args` — and from nowhere else | [Demand roots](#demand-roots--what-makes-a-getter-run) |
|
|
136
136
|
| **Modularity** | `mount=` grafts a module onto the one tree; `state: path` mounts a subtree onto a component; the per-property form maps single keys, and a mounted component's getters are exported at its mount point | [Volumes](#mounting-additional-state-mount) · [Whole-object mount](#whole-object-mount-state-path) |
|
|
137
137
|
| **Components** | Two mutually exclusive mechanisms: a JavaScript class with `bind-component`, or HTML-only DCC | [Choosing a mechanism](#choosing-a-component-mechanism) |
|
|
138
138
|
| **Wiring to other elements** | The wc-bindable protocol, spread (`...: obj`), `#init=` / `#sync=` authority, property-to-attribute mirroring | [Binding authority](#binding-authority-init--sync) · [Spread](#spread-binding) · [Inputs](#inputs-and-attribute-mirror) |
|
|
139
139
|
| **Tokens** | Command tokens call an element's methods from state; event tokens carry the element's events back | [Command token](#command-token-method-binding) · [Event token](#event-token-event-binding) |
|
|
140
|
-
| **Time** | `$
|
|
140
|
+
| **Time** | `$stream` folds an async source, `$watch` reacts headlessly, `$scan` owns an accumulation that outlives both | [Choosing a time mechanism](#choosing-a-time-mechanism) |
|
|
141
141
|
| **Initialization and lifecycle** | Six ways to supply the state; `$connectedCallback` … `$stateReadyCallback`; `bootstrapState()` / `createState()` | [State initialization](#state-initialization) · [Lifecycle hooks](#lifecycle-hooks) · [API reference](#api-reference) |
|
|
142
142
|
| **Diagnostics** | Unresolved paths, index arity, wildcard rank and getter cycles are reported; one failing binding stays confined, and neither values nor the DOM are rolled back | [Diagnostics](#diagnostics-and-failure-handling) |
|
|
143
143
|
| **Delivery** | Zero runtime dependencies, no build step, ESM, one CDN `/auto` tag; no `unsafe-eval`, Trusted Types supported | [Installation](#installation) · [docs/csp.md](../../docs/csp.md) |
|
|
@@ -187,15 +187,15 @@ leaves features out, it can compose them instead:
|
|
|
187
187
|
<script type="importmap">
|
|
188
188
|
{
|
|
189
189
|
"imports": {
|
|
190
|
-
"@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.
|
|
191
|
-
"@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.
|
|
192
|
-
"@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.
|
|
190
|
+
"@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.3.0/dist/split/core.js",
|
|
191
|
+
"@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.3.0/dist/split/features/temporal.js",
|
|
192
|
+
"@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@3.3.0/dist/split/features/scopes.js"
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
</script>
|
|
196
196
|
<script type="module">
|
|
197
197
|
import { bootstrapState, installFeatures } from '@wcstack/state/core';
|
|
198
|
-
import temporal from '@wcstack/state/features/temporal'; // $watch / $scan / $
|
|
198
|
+
import temporal from '@wcstack/state/features/temporal'; // $watch / $scan / $stream
|
|
199
199
|
import scopes from '@wcstack/state/features/scopes'; // bind-component, mount=, DCC
|
|
200
200
|
|
|
201
201
|
installFeatures([temporal, scopes]);
|
|
@@ -203,6 +203,12 @@ leaves features out, it can compose them instead:
|
|
|
203
203
|
</script>
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
+
Call `installFeatures([...])` **before** `bootstrapState()`, as above: a feature can register a tag of its
|
|
207
|
+
own (`<wcs-ssr>`), and bootstrapping is what defines the tags. The reverse order is not an error — a
|
|
208
|
+
feature installed afterwards catches up and defines its tags against the registries already
|
|
209
|
+
bootstrapped — but keeping the declared order means every tag is defined before the first element
|
|
210
|
+
upgrades.
|
|
211
|
+
|
|
206
212
|
Load the split form from the package's own files — jsDelivr's plain, version-pinned `/npm/` path as
|
|
207
213
|
above (it does not read `exports`, so name the file under `dist/split/`), or a bundler — and **never
|
|
208
214
|
through `esm.run`**. Its `+esm` endpoint re-bundles every entry on the server and inlines the shared
|
|
@@ -214,11 +220,11 @@ so the browser evaluates the engine once. Integrity for this form: [docs/sri.md
|
|
|
214
220
|
| Entry | What it adds |
|
|
215
221
|
|---|---|
|
|
216
222
|
| `@wcstack/state/core` | The binding engine: `data-wcs`, `for` / `if`, path getters, filters, events, `$command` / `$on`, `bootstrapState`, `installFeatures` |
|
|
217
|
-
| `@wcstack/state/features/temporal` | `$watch`, `$scan`, `$
|
|
223
|
+
| `@wcstack/state/features/temporal` | `$watch`, `$scan`, `$stream` |
|
|
218
224
|
| `@wcstack/state/features/scopes` | `bind-component`, `mount=` volumes, overlay exports, DCC (`data-wc-definition`) |
|
|
219
225
|
| `@wcstack/state/features/recursion` | `$recursion` and `**` paths |
|
|
220
226
|
| `@wcstack/state/features/ssr` | `enable-ssr`: server rendering and hydration |
|
|
221
|
-
| `@wcstack/state/features/formats` | The formatting filters (`
|
|
227
|
+
| `@wcstack/state/features/formats` | The formatting filters (`upper`, `date`, `round`, `truncate`, …). The core answers only `not`, which `if` / `else` need |
|
|
222
228
|
| `@wcstack/state/features/devtools` | The DevTools hook protocol source |
|
|
223
229
|
| `@wcstack/state/features/diagnostics` | Development-time warnings: a bound / `$watch` / `$scan` path that does not resolve on the state is reported with a did-you-mean. Without it the page stays silent — thrown errors keep their full messages either way |
|
|
224
230
|
| `@wcstack/state/define` | `defineState` and the types only — no runtime at all |
|
|
@@ -226,8 +232,11 @@ so the browser evaluates the engine once. Integrity for this form: [docs/sri.md
|
|
|
226
232
|
A declaration whose feature is missing does not fail quietly: it throws
|
|
227
233
|
`[wcs/feature-not-installed] … install it with installFeatures([...]) from "@wcstack/state/features/…"`
|
|
228
234
|
when the state is defined, and a filter with no implementation throws `[wcs/filter-unknown]` when the
|
|
229
|
-
bindings are planned.
|
|
230
|
-
|
|
235
|
+
bindings are planned. A volume (`<wcs-state mount=…>`) declaring `$watch` throws the same
|
|
236
|
+
`[wcs/feature-not-installed]` when it grafts, because a volume's state never passes through the
|
|
237
|
+
element's own declaration gate. Installing is idempotent, and every entry shares one core chunk —
|
|
238
|
+
a feature never carries a second copy of the engine, and never a copy of another feature either
|
|
239
|
+
(a call across that seam goes through a receptacle the owning feature fills on install).
|
|
231
240
|
|
|
232
241
|
## Basic Usage
|
|
233
242
|
|
|
@@ -334,7 +343,7 @@ There is **one state tree per root**. To split state across modules, mount a vol
|
|
|
334
343
|
<div data-wcs="textContent: cart.total"></div>
|
|
335
344
|
```
|
|
336
345
|
|
|
337
|
-
A volume may declare getters, `$watch`, `$listKeys`, `$
|
|
346
|
+
A volume may declare getters, `$watch`, `$listKeys`, `$renderedCallback`, and `$connectedCallback`/`$disconnectedCallback` — all relative to its mount path. `$errorCallback` is root-only (a binding failure is reported once, to the tree's owner). Load order does not matter (a volume connected before the root is grafted when the root registers). If the root `<wcs-state>` fails to initialize, the volumes already waiting for it settle with a report of their own instead of waiting forever. That report is the end of the line for those volumes: a volume reported as an orphan does not graft itself later, so connecting a corrected root afterwards does not bring it back. A volume that settles without grafting — orphaned, failed to load, or failed to graft — releases its mount slot, and so does a volume detached while it is still loading or waiting for its root. Such a volume takes the slot back when it is re-attached to the same root, or otherwise just before it grafts, and still grafts as before when the slot is free — even while detached; if another volume took the slot in the meantime, it reports that and does not graft. A synchronous throw from a volume's `$connectedCallback` is reported like an asynchronous one, and the volume counts as grafted. To recover without reloading the page, remove the broken root and the orphaned volumes and add new elements. A grafted volume keeps its slot even when detached, because its data stays in the tree. Mount paths must be static (`*`, `$`, `#`, `@` are rejected). Changing `mount` after the element has initialized is not supported: the change is ignored with a console warning — remove the element and add a new one with the desired path.
|
|
338
347
|
|
|
339
348
|
**Injecting root paths into a volume (3.1).** When a volume's code needs a path outside its own subtree, write the injection on the volume element's `data-wcs`, in the same form as a component's partial mount:
|
|
340
349
|
|
|
@@ -342,7 +351,7 @@ A volume may declare getters, `$watch`, `$listKeys`, `$updatedCallback`, and `$c
|
|
|
342
351
|
<wcs-state mount="cart" src="./cart.js" data-wcs="state.taxRate: settings.taxRate"></wcs-state>
|
|
343
352
|
```
|
|
344
353
|
|
|
345
|
-
Inside `cart.js` — getters, methods, `$watch`, `$listKeys` and the lifecycle callbacks — `this.taxRate` reads and writes the root's `settings.taxRate`. The read records a dependency, so `get total()` re-evaluates when `settings.taxRate` changes. `$watch: { taxRate() {…} }` fires on changes to `settings.taxRate`, and `$
|
|
354
|
+
Inside `cart.js` — getters, methods, `$watch`, `$listKeys` and the lifecycle callbacks — `this.taxRate` reads and writes the root's `settings.taxRate`. The read records a dependency, so `get total()` re-evaluates when `settings.taxRate` changes. `$watch: { taxRate() {…} }` fires on changes to `settings.taxRate`, and `$renderedCallback` receives that update under the inner name `taxRate`. The injected name exists only inside the volume's code: the page keeps reading `settings.taxRate`, and the tree has no `cart.taxRate`.
|
|
346
355
|
|
|
347
356
|
- **One key at a time.** The left side is a single `state.<key>`. `state: …` cannot move the whole volume; change `mount` for that.
|
|
348
357
|
- **The injection wins over the volume's own key.** A data key of the same name (`taxRate: 0`) is not grafted. An accessor or method of the same name would make `this.taxRate` ambiguous, so that volume reports an error and does not graft.
|
|
@@ -355,8 +364,8 @@ Inside `cart.js` — getters, methods, `$watch`, `$listKeys` and the lifecycle c
|
|
|
355
364
|
|---|---|---|---|
|
|
356
365
|
| Data keys, getters, setters, methods | yes | yes, relative to `p` | yes — own keys are private unless the host maps them; getters are exported |
|
|
357
366
|
| `$connectedCallback` / `$disconnectedCallback` | yes | yes, relative to `p` | yes |
|
|
358
|
-
| `$watch`, `$listKeys`, `$
|
|
359
|
-
| `$
|
|
367
|
+
| `$watch`, `$listKeys`, `$renderedCallback` | yes | yes, relative to `p` | not run — one `wcs/mount-dollar-declaration` warning |
|
|
368
|
+
| `$stream`, `$scan`, `$recursion`, `**` getters | yes | rejected with an error before grafting | not run — warning |
|
|
360
369
|
| `$commandTokens`, `$eventTokens`, `$on` | yes | not run — warning | not run — warning |
|
|
361
370
|
| `$errorCallback` | yes | not run — warning (silent before 3.0) | not run — warning (silent before 3.0) |
|
|
362
371
|
| An initialization failure | reported once; `connectedCallbackPromise` rejects | settles without grafting and releases its slot; `connectedCallbackPromise` resolves (a missing `scopes` feature rejects it) | reported once; the component's `connectedCallbackPromise` rejects |
|
|
@@ -653,7 +662,9 @@ Runtime reads `customClass.wcBindable.properties + inputs` and expands each name
|
|
|
653
662
|
<wcs-fetch data-wcs="...: usersFetch; status: alternateStatus"></wcs-fetch>
|
|
654
663
|
```
|
|
655
664
|
|
|
656
|
-
**`undefined` is "no opinion"** — when an expanded state path resolves to `undefined` (e.g. the slot object doesn't initialize that input), the property write is **skipped** and the element keeps its own default. You only need to initialize the paths you actually use; `usersFetch: { value: null, loading: false }` is enough even though `<wcs-fetch>` also declares `method` / `manual` / `body`. To explicitly clear a value, assign `null` — `null` is always written. (This skip applies to every property binding that feeds an element input, not just spread; with `config.debug` each skipped write is logged via `console.debug`.) **Display surfaces are different (3.0):** `textContent` / `innerText` / `innerHTML`, mustache text, `attr.*` and `
|
|
665
|
+
**`undefined` is "no opinion"** — when an expanded state path resolves to `undefined` (e.g. the slot object doesn't initialize that input), the property write is **skipped** and the element keeps its own default. You only need to initialize the paths you actually use; `usersFetch: { value: null, loading: false }` is enough even though `<wcs-fetch>` also declares `method` / `manual` / `body`. To explicitly clear a value, assign `null` — `null` is always written. (This skip applies to every property binding that feeds an element input, not just spread; with `config.debug` each skipped write is logged via `console.debug`.) **Display surfaces are different (3.0):** `textContent` / `innerText` / `innerHTML`, mustache text, `attr.*`, `style.*` and `class.*` have no element default worth keeping, so `undefined` and `null` both mean "no value" there — the text becomes empty, the attribute or style is removed, and the class is taken off. Before 3.0 a `textContent:` binding skipped `undefined` too, which left the previous row's text in a reused list row, and an attribute got the string `"undefined"` / `"null"`. `class.*` is the narrow case: only `undefined` and `null` mean "no value"; **any other non-boolean still throws**, because a class binding is a boolean toggle and a truthy string is far more likely a mistake than an intent. Write `class.on: flag|truthy` when you mean "treat it as truthy".
|
|
666
|
+
|
|
667
|
+
**Filters keep the contract.** The emptiness test runs on the value *after* the filters, so a filter that builds its result with `String(value)` used to turn an absent value back into the characters `"undefined"` — `attr.title: x|trim` wrote `title="undefined"` where the unfiltered `attr.title: x` removes the attribute. The formatting family (`upper`, `lower`, `capitalize`, `trim`, `slice`, `substr`, `padStart`, `padEnd`, `repeat`, `reverse`, `truncate`, `unit`) now passes `undefined` / `null` straight through, so the apply side still empties the text and removes the attribute. Three groups deliberately do **not**: the filters for which an absent value *is* the input (`defaults`, `coalesce`, `nullIfEmpty`, `boolean`, `truthy`, `falsy`, `not`, `eq`, `ne`), the conversions whose whole job is to convert (`int`, `float`, `number`, `string` — `undefined|string` is still `"undefined"`), and the filters that check the value's type (the number, date and array families, e.g. `toFixed` / `date` / `join`). Those last ones throw, which is confined to the one binding and reported through `$errorCallback` — put a `coalesce` in front when the value can legitimately be absent (`price|coalesce(0)|toFixed(2)`).
|
|
657
668
|
|
|
658
669
|
**Constraints**:
|
|
659
670
|
|
|
@@ -727,7 +738,7 @@ Inside a `for` loop, paths starting with `.` are expanded relative to the loop's
|
|
|
727
738
|
|---|---|---|
|
|
728
739
|
| `.name` | `users.*.name` | Property of the current element |
|
|
729
740
|
| `.` | `users.*` | The current element itself |
|
|
730
|
-
| `.name\|
|
|
741
|
+
| `.name\|upper` | `users.*.name\|upper` | Filters are preserved |
|
|
731
742
|
|
|
732
743
|
For primitive arrays, `.` refers to the element value directly:
|
|
733
744
|
|
|
@@ -1060,9 +1071,9 @@ The rule: **read only through `this`, and don't write state or touch the DOM fro
|
|
|
1060
1071
|
|
|
1061
1072
|
| API | Use it for |
|
|
1062
1073
|
|---|---|
|
|
1063
|
-
| `this.$
|
|
1074
|
+
| `this.$dependOn(path)` | Register an extra dependency so this getter is dirtied when that path changes |
|
|
1064
1075
|
| `this.$postUpdate(path)` | Announce that an untracked input changed, from outside the getter |
|
|
1065
|
-
| `this.$
|
|
1076
|
+
| `this.$untracked(fn)` | Read a path *without* registering it as a dependency (the inverse) |
|
|
1066
1077
|
| `this.$eq(path, key)` / `$eqPath(path, keyPath)` / `$eqIndex(path)` | A keyed subscription: "is `path` equal to this row's key?" without a pattern dependency (see [Keyed selection](#keyed-selection-eq--eqpath--eqindex)) |
|
|
1067
1078
|
|
|
1068
1079
|
```javascript
|
|
@@ -1086,9 +1097,9 @@ Three rules decide what the dependency graph sees. None of them matters until yo
|
|
|
1086
1097
|
| **Reads inside a setter are not tracked.** A setter is an imperative assignment, not a derivation, so nothing it reads becomes a dependency of anything | A setter that reads `this.a` to decide what to write does not run again when `a` changes — only a getter re-runs |
|
|
1087
1098
|
| **The same-value guard applies to primitives only.** A primitive write `Object.is`-equal to the current value is dropped before anything is enqueued; an object or array write always passes, even the same reference | Assigning the same string again fires nothing; assigning the same object again re-fires its bindings and `$watch` (`config.sameValueGuard`; a `semantics: "event"` property is exempt either way) |
|
|
1088
1099
|
|
|
1089
|
-
The first rule is the one static analysis can catch: `wcs-validate` and the VS Code extension report `wcs/getter-untracked-read` when a getter reads `this.form.name` and the document writes `form.name` somewhere (a `value:` binding, a spread, `this["form.name"] = …`). A root that is only ever replaced wholesale — router params, a `$
|
|
1100
|
+
The first rule is the one static analysis can catch: `wcs-validate` and the VS Code extension report `wcs/getter-untracked-read` when a getter reads `this.form.name` and the document writes `form.name` somewhere (a `value:` binding, a spread, `this["form.name"] = …`). A root that is only ever replaced wholesale — router params, a `$stream` fold — is left alone.
|
|
1090
1101
|
|
|
1091
|
-
`$
|
|
1102
|
+
`$untracked(fn)` applies the setter rule to a getter on purpose: reads inside `fn` are not tracked. `$dependOn(path)` is the escape hatch for the first rule.
|
|
1092
1103
|
|
|
1093
1104
|
### Keyed selection (`$eq` / `$eqPath` / `$eqIndex`)
|
|
1094
1105
|
|
|
@@ -1121,6 +1132,7 @@ What reaches the rows:
|
|
|
1121
1132
|
- **A write to an object above `path`** — `this.sel = { id: 2 }` for `$eq("sel.id", …)`: the same two rows, keyed by the value `path` had under the old object and has under the new one.
|
|
1122
1133
|
- **A `path` that is a getter, or sits under one** (`$eq("current.id", …)` with `get current()`): its value changes without a write to it, so the calls fall back to an ordinary tracked read. The selection stays correct, but a change re-evaluates every row, as without the keyed form. Point `path` at the written state (`selectedId`) to keep the two-row cost. The State pane of `@wcstack/devtools` counts the subscriptions per path under **Keyed selection** and marks a path that fell back this way with a `tracked` badge (3.0).
|
|
1123
1134
|
- **No type conversion:** `"2"` does not match the id `2`. An `<input>` or `<select>` writes strings, so convert on the way in (`value|number: selectedId`) or keep the ids as strings.
|
|
1135
|
+
- **Inside a scope, `path` is relative** — like every other `$` API that takes a path. In a mounted component (`bind-component`) and in a volume (`<wcs-state mount="cart">`), `$eq("selectedId", …)`, `$eqPath`'s two paths, `$eqIndex` and `$dependOn` all resolve against that scope (`cart.selectedId`), not against the root. `$untracked` takes a callback, so nothing is translated there.
|
|
1124
1136
|
|
|
1125
1137
|
### Loop Index Variables (`$1`, `$2`, ...)
|
|
1126
1138
|
|
|
@@ -1152,7 +1164,7 @@ You can also display the loop index directly in templates:
|
|
|
1152
1164
|
|
|
1153
1165
|
```html
|
|
1154
1166
|
<template data-wcs="for: items">
|
|
1155
|
-
<td>{{ $1|
|
|
1167
|
+
<td>{{ $1|add(1) }}</td> <!-- 1-based row number -->
|
|
1156
1168
|
</template>
|
|
1157
1169
|
```
|
|
1158
1170
|
|
|
@@ -1166,8 +1178,8 @@ Inside state objects (getters / methods), the following APIs are available via `
|
|
|
1166
1178
|
| `this.$setAll(path, indexes, value, options?)` | Write to every address matching a wildcard path |
|
|
1167
1179
|
| `this.$resolve(path, indexes, value?)` | Resolve a wildcard path with specific indexes |
|
|
1168
1180
|
| `this.$postUpdate(path)` | Manually trigger update notification for a path |
|
|
1169
|
-
| `this.$
|
|
1170
|
-
| `this.$
|
|
1181
|
+
| `this.$dependOn(path)` (3.2; old name `$trackDependency`) | Manually register a dependency for cache invalidation |
|
|
1182
|
+
| `this.$untracked(fn)` (3.2; old name `$untrackDependency`) | Read values inside fn without registering dependencies |
|
|
1171
1183
|
| `this.$eq(path, key)` / `$eqPath(path, keyPath)` / `$eqIndex(path, level?)` | Keyed subscription for "is this row's key the value of `path`?" (see [Keyed selection](#keyed-selection-eq--eqpath--eqindex)) |
|
|
1172
1184
|
| `this.$command.<name>` | Access a `CommandToken` declared in `$commandTokens` (see [Command Token](#command-token-method-binding)) |
|
|
1173
1185
|
| `this.$stateElement` | Access to the `IStateElement` instance |
|
|
@@ -1339,7 +1351,7 @@ k=2 nodes.*.children.*.children.*
|
|
|
1339
1351
|
| `$getAll(path, [])`, **explicit** | **Union of every depth** — depth-first, pre-order, ascending index |
|
|
1340
1352
|
| `$getAll(path, [i, …])` | Rejected: a prefix cannot say which depth it applies to (`wcs/recursion-getall-form`) |
|
|
1341
1353
|
| `$setAll(path, [], value)` | Broadcast to every depth, in that same order |
|
|
1342
|
-
| `$resolve`, `$postUpdate`, `$
|
|
1354
|
+
| `$resolve`, `$postUpdate`, `$dependOn`, `$watch` keys, `$listKeys` keys, `data-wcs` in markup, direct assignment | Rejected (`wcs/recursion-unsupported`) |
|
|
1343
1355
|
|
|
1344
1356
|
The bound forms need a depth to bind to, so they only resolve **inside** a recursive getter — or inside an ordinary row getter under the anchor, or an event handler bound to such a row — each of those carries a real `ListIndex` to read the depth from. Read `this["nodes.**.value"]` from the top level and you get `wcs/recursion-context`, not a silent guess at which node you meant. The depth is read from the innermost evaluation frame only, the same frame the row index comes from: a plain getter that a recursive getter calls (`get "nodes.**.x"() { return this.helper }` with `get helper() { return this["nodes.**.value"] }`) has no row of its own and gets `wcs/recursion-context` too — read `**` in the recursive getter and pass the value on. The union form needs no depth, so it can be read from anywhere: a top-level getter, a plain row getter, a method.
|
|
1345
1357
|
|
|
@@ -1408,8 +1420,8 @@ Every other form is refused *before* the walk writes anything, so a rejected cal
|
|
|
1408
1420
|
| omitted indexes | The write API takes no context, so there is no depth to bind to — pass `[]` |
|
|
1409
1421
|
| a mapper function | `(current, ...indexes)` has a different arity at every depth |
|
|
1410
1422
|
| `{ spread: true }` | Handing a flat array to a tree needs the author to know the walk order |
|
|
1411
|
-
| `nodes.**`, `nodes.**.children`, `nodes.**.children.*`, `nodes.**.children.length` — and, for a multi-segment repeat such as `branch.children.*`, the `nodes.**.branch` on the way to the list. Index spellings fold to the same forms
|
|
1412
|
-
| `nodes.**.total`, or a path inside its value | A recursive getter has no setter — write what it derives from (`wcs/recursion-readonly`) |
|
|
1423
|
+
| `nodes.**`, `nodes.**.children`, `nodes.**.children.*`, `nodes.**.children.length` — and, for a multi-segment repeat such as `branch.children.*`, the `nodes.**.branch` on the way to the list. Index spellings fold to the same forms, so `nodes.**.children.0` is a child node | Writing the structure itself (assigning `length` truncates the list) invalidates the child addresses this very write already resolved (`wcs/recursion-structural-write`) |
|
|
1424
|
+
| `nodes.**.total`, or a path inside its value — index spellings fold to the same form too, so `nodes.**.children.0.total` is the getter | A recursive getter has no setter — write what it derives from (`wcs/recursion-readonly`) |
|
|
1413
1425
|
|
|
1414
1426
|
The read-only rule does not depend on spelling `**`. A recursive getter's concrete expansions — `nodes.*.total`, `nodes.*.children.*.total`, … — are refused at the write entry as well, whether the write is a fixed-arity `$setAll`, a `$resolve(path, indexes, value)` or a direct assignment, and whether or not that depth has been materialized yet. Before this check, an unmaterialized expansion looked like a plain missing key and the write landed on the node object, pinning the assigned value as the getter's cached result.
|
|
1415
1427
|
|
|
@@ -1465,7 +1477,7 @@ Each of these is a diagnostic, never a silent reinterpretation:
|
|
|
1465
1477
|
- Recursive setters, a `**` getter whose suffix names the structure (`get "nodes.**.children"()`), a concrete getter with the same name as a `**` getter's expansion, and writing through `**` by assignment (`this["nodes.**.x"] = v`, `++` included)
|
|
1466
1478
|
- In a recursive `$setAll`: a mapper, `{ spread: true }`, omitted indexes, a non-empty prefix, or a non-array `indexes`. The write API has no evaluation context to bind a depth to, so `[]` is mandatory
|
|
1467
1479
|
- In a recursive `$getAll`: a non-empty prefix, or a non-array `indexes`. **Omitting the indexes is valid** — inside a recursive getter it is the bound form, and it reads the depth being evaluated
|
|
1468
|
-
- `**` in `data-wcs`, in `$watch` or `$listKeys` keys, or in `$resolve` / `$postUpdate` / `$
|
|
1480
|
+
- `**` in `data-wcs`, in `$watch` or `$listKeys` keys, or in `$resolve` / `$postUpdate` / `$dependOn`
|
|
1469
1481
|
- `$recursion` and `**` getters in a volume (`mount=`) or a mounted component (`bind-component`) — declare them on the root state
|
|
1470
1482
|
- A recursive `<template>`, a `$depth` variable, and a public `maxDepth` option — none of the three exist
|
|
1471
1483
|
|
|
@@ -1501,7 +1513,9 @@ export default {
|
|
|
1501
1513
|
|
|
1502
1514
|
## Filters
|
|
1503
1515
|
|
|
1504
|
-
|
|
1516
|
+
48 built-in filters are available for both input (DOM → state) and output (state → DOM) directions.
|
|
1517
|
+
|
|
1518
|
+
**3.2 renamed some filters** (requirement B12). The tables show the canonical names, with the old name in parentheses. The old names work the same through 3.x and are removed in 4.0. Lint and the VS Code extension flag an old name with an info diagnostic (`wcs/name-alias`) that suggests the canonical one. On a page that uses 3.1 or earlier, write the old names.
|
|
1505
1519
|
|
|
1506
1520
|
### Comparison
|
|
1507
1521
|
|
|
@@ -1509,7 +1523,7 @@ export default {
|
|
|
1509
1523
|
|---|---|---|
|
|
1510
1524
|
| `eq(value)` | Equal | `count\|eq(0)` → `true/false` |
|
|
1511
1525
|
| `ne(value)` | Not equal | `count\|ne(0)` |
|
|
1512
|
-
| `not` |
|
|
1526
|
+
| `not` | Invert truthiness — `0`, `""`, `null` and `undefined` all give `true` (3.x; it used to throw on a non-boolean, which made `else:` render neither branch) | `isActive\|not` |
|
|
1513
1527
|
| `lt(n)` | Less than | `count\|lt(10)` |
|
|
1514
1528
|
| `le(n)` | Less than or equal | `count\|le(10)` |
|
|
1515
1529
|
| `gt(n)` | Greater than | `count\|gt(0)` |
|
|
@@ -1519,8 +1533,8 @@ export default {
|
|
|
1519
1533
|
|
|
1520
1534
|
| Filter | Description | Example |
|
|
1521
1535
|
|---|---|---|
|
|
1522
|
-
| `
|
|
1523
|
-
| `
|
|
1536
|
+
| `add(n)` (`inc`) | Add | `count\|add(1)` |
|
|
1537
|
+
| `sub(n)` (`dec`) | Subtract | `count\|sub(1)` |
|
|
1524
1538
|
| `mul(n)` | Multiply | `price\|mul(1.1)` |
|
|
1525
1539
|
| `div(n)` | Divide | `total\|div(100)` |
|
|
1526
1540
|
| `mod(n)` | Modulo | `index\|mod(2)` |
|
|
@@ -1531,7 +1545,7 @@ export default {
|
|
|
1531
1545
|
|
|
1532
1546
|
| Filter | Description | Example |
|
|
1533
1547
|
|---|---|---|
|
|
1534
|
-
| `
|
|
1548
|
+
| `toFixed(n)` (`fix`) | Fixed decimal places | `price\|toFixed(2)` → `"100.00"` |
|
|
1535
1549
|
| `round(n?)` | Round | `value\|round(2)` |
|
|
1536
1550
|
| `floor(n?)` | Floor | `value\|floor` |
|
|
1537
1551
|
| `ceil(n?)` | Ceiling | `value\|ceil` |
|
|
@@ -1543,16 +1557,17 @@ export default {
|
|
|
1543
1557
|
|
|
1544
1558
|
| Filter | Description | Example |
|
|
1545
1559
|
|---|---|---|
|
|
1546
|
-
| `uc` | Upper case | `name\|
|
|
1547
|
-
| `lc` | Lower case | `name\|
|
|
1548
|
-
| `cap` | Capitalize | `name\|
|
|
1560
|
+
| `upper` (`uc`) | Upper case | `name\|upper` |
|
|
1561
|
+
| `lower` (`lc`) | Lower case | `name\|lower` |
|
|
1562
|
+
| `capitalize` (`cap`) | Capitalize | `name\|capitalize` |
|
|
1549
1563
|
| `trim` | Trim whitespace | `text\|trim` |
|
|
1550
1564
|
| `slice(n)` | Slice string | `text\|slice(5)` |
|
|
1551
|
-
| `substr(start, length)` | Substring | `text\|substr(0,10)` |
|
|
1552
|
-
| `
|
|
1553
|
-
| `
|
|
1554
|
-
| `
|
|
1555
|
-
| `
|
|
1565
|
+
| `substr(start, length)` | Substring (both arguments required) | `text\|substr(0,10)` |
|
|
1566
|
+
| `padStart(n, char?)` (`pad`) | Pad the start (default `0`) | `id\|padStart(5,0)` → `"00001"` |
|
|
1567
|
+
| `padEnd(n, char?)` | Pad the end (default a space; 3.2) | `code\|padEnd(8)` |
|
|
1568
|
+
| `repeat(n)` (`rep`) | Repeat | `text\|repeat(3)` |
|
|
1569
|
+
| `reverse` (`rev`) | Reverse | `text\|reverse` |
|
|
1570
|
+
| `truncate(n, suffix?)` | Shorten and append an ellipsis (default `…`, one U+2026 character) | `title\|truncate(20)` |
|
|
1556
1571
|
| `join(sep?)` | Join an array (default `", "`) | `tags\|join` / `tags\|join(/)` |
|
|
1557
1572
|
|
|
1558
1573
|
### Type Conversion
|
|
@@ -1564,17 +1579,19 @@ export default {
|
|
|
1564
1579
|
| `boolean` | To boolean | `value\|boolean` |
|
|
1565
1580
|
| `number` | To number | `value\|number` |
|
|
1566
1581
|
| `string` | To string | `value\|string` |
|
|
1567
|
-
| `null` |
|
|
1582
|
+
| `nullIfEmpty` (`null`) | Turn an empty string into `null` (anything else passes through) | `value\|nullIfEmpty` |
|
|
1568
1583
|
|
|
1569
1584
|
### Date / Time
|
|
1570
1585
|
|
|
1571
1586
|
| Filter | Description | Example |
|
|
1572
1587
|
|---|---|---|
|
|
1573
|
-
| `date(loc?)` | Date format | `
|
|
1574
|
-
| `time(loc?)` | Time format | `
|
|
1575
|
-
| `datetime(loc?)` | Date + Time | `
|
|
1576
|
-
| `ymd(sep?)` | YYYY-MM-DD | `
|
|
1577
|
-
| `hms(sep?)` | HH:MM:SS | `
|
|
1588
|
+
| `date(loc?)` | Date format | `createdAt\|date` / `createdAt\|date(ja-JP)` |
|
|
1589
|
+
| `time(loc?)` | Time format | `createdAt\|time` |
|
|
1590
|
+
| `datetime(loc?)` | Date + Time | `createdAt\|datetime(en-US)` |
|
|
1591
|
+
| `ymd(sep?)` | YYYY-MM-DD (default `-`) | `createdAt\|ymd` / `createdAt\|ymd(/)` |
|
|
1592
|
+
| `hms(sep?)` | HH:MM:SS (default `:`) | `createdAt\|hms` / `createdAt\|hms(-)` |
|
|
1593
|
+
|
|
1594
|
+
**All five take a `Date`,** not a timestamp — a value of any other type is rejected with "requires a date value". State loaded from JSON carries a number or an ISO string, so convert it where it is read rather than in the binding: `get createdAt() { return new Date(this.createdAtMs); }`. `loc?` defaults to `config.locale`, and the default is re-read on **every** apply, so a locale settled after the bindings were planned still takes effect (an explicit `date(ja-JP)` is part of the binding expression and is fixed when it is planned).
|
|
1578
1595
|
|
|
1579
1596
|
### Boolean / Default
|
|
1580
1597
|
|
|
@@ -1582,7 +1599,8 @@ export default {
|
|
|
1582
1599
|
|---|---|---|
|
|
1583
1600
|
| `truthy` | Truthy check — JavaScript's own truthiness, the same as `boolean` (so `0n` is falsy since 3.0) | `value\|truthy` |
|
|
1584
1601
|
| `falsy` | Falsy check (JavaScript's truthiness; `defaults` uses the same test) | `value\|falsy` |
|
|
1585
|
-
| `defaults(v)` | Fallback value | `name\|defaults(Anonymous)` |
|
|
1602
|
+
| `defaults(v)` | Fallback for any falsy value (`0`, `false` and `""` included) | `name\|defaults(Anonymous)` |
|
|
1603
|
+
| `coalesce(v)` | Fallback for `null` / `undefined` only (`0`, `false` and `""` stay; 3.2) | `count\|coalesce(0)` |
|
|
1586
1604
|
|
|
1587
1605
|
### Filter Chaining
|
|
1588
1606
|
|
|
@@ -1594,7 +1612,15 @@ Filters can be chained with `|`:
|
|
|
1594
1612
|
|
|
1595
1613
|
A filter is resolved when the bindings are planned. An unknown name throws `[wcs/filter-unknown]` (with a did-you-mean), and — as of 3.0 — an argument count outside what the filter accepts throws `[wcs/filter-arity]` (`join(a,b)`: "accepts at most 1 argument(s) (2 given)"), the same code and bounds lint reports. Arguments are cached by their structure, so `join('a,b')` and `join(a)` are never confused.
|
|
1596
1614
|
|
|
1597
|
-
**Argument literals are typed (3.0).** An unquoted `true`, `false`, `null` or number is that value; a quoted argument is a string. The comparison filters use it for booleans and `null`: `done|eq(true)` matches `true` (before 3.0 it compared with the string `"true"` and never matched), `eq('true')` does not, and `eq(null)` matches `null`. Numbers and strings compare as before — a numeric value against the number, a string value against the text — so a form value `"1"` still matches `eq(1)`. `defaults(v)` returns the typed value: `defaults(0)` gives `0`, `defaults('0')` gives `"0"`, `defaults(null)` gives `null`.
|
|
1615
|
+
**Argument literals are typed (3.0).** An unquoted `true`, `false`, `null` or number is that value; a quoted argument is a string. The comparison filters use it for booleans and `null`: `done|eq(true)` matches `true` (before 3.0 it compared with the string `"true"` and never matched), `eq('true')` does not, and `eq(null)` matches `null`. Numbers and strings compare as before — a numeric value against the number, a string value against the text — so a form value `"1"` still matches `eq(1)`. A **numeric** value against a typed `true` / `false` / `null` is simply "not equal": `selectedId|eq(null)` is `true` while the id is `null` and `false` once it is a number. (Until 3.2 that combination threw `requires a number as option` on every apply, so a path that is sometimes `null` and sometimes numeric broke the binding the moment a number arrived.) Only an unquoted non-number — `eq(abc)` against a numeric value — still reports the type mismatch; `eq` accepts values of any type, so `status|eq(active)` cannot be judged before the value is known. `defaults(v)` returns the typed value: `defaults(0)` gives `0`, `defaults('0')` gives `"0"`, `defaults(null)` gives `null`.
|
|
1616
|
+
|
|
1617
|
+
**Quoting rules.** Quotes (`'` or `"`) mark a literal, and the argument grammar is deliberately small:
|
|
1618
|
+
|
|
1619
|
+
- Inside quotes, `,` `;` `|` and `:` are ordinary characters, so `join(', ')`, `join(': ')` and `join(';')` all parse.
|
|
1620
|
+
- Whitespace is trimmed **outside** the quotes only: `padStart(5, ' ')` pads with a space, `toFixed( 2 )` is `toFixed(2)`.
|
|
1621
|
+
- **There is no escape character.** A quote of the same kind cannot appear inside its own literal — write `"it's"` rather than `'it\'s'`. (`'it\'s'` is an unterminated quote and throws `[wcs/binding-syntax]`.)
|
|
1622
|
+
- Adjacent runs are concatenated into one argument: `'a' 'b'` is the single string `a b`, and `1'2'` is the string `12` (any quote in the argument makes the whole argument a string).
|
|
1623
|
+
- An unterminated quote is rejected; a trailing empty argument is dropped (`filter()` takes no arguments), while a leading or middle empty argument keeps its position (`defaults(,)` passes one empty string).
|
|
1598
1624
|
|
|
1599
1625
|
## Web Component Binding
|
|
1600
1626
|
|
|
@@ -1716,7 +1742,11 @@ customElements.define("user-card", UserCard);
|
|
|
1716
1742
|
> component that declares a default for a mapped key (`state = { message: "" }` together with
|
|
1717
1743
|
> `state.message: ...`) reads the host value; the default is simply not used. (In 2.x R1 made
|
|
1718
1744
|
> that own key private and it hid the host value, with a `wcs/mount-own-key-shadow` warning.)
|
|
1719
|
-
> R1 still keeps every *unmapped* own key private.
|
|
1745
|
+
> R1 still keeps every *unmapped* own key private. This is about a **one-segment** entry: a deeper
|
|
1746
|
+
> entry (`state.a.b: outer.b`) does not touch the privacy of `a`, so if the component declares `a`
|
|
1747
|
+
> itself, `a` and everything under it stay private and the entry never reaches it. The same holds
|
|
1748
|
+
> for an entry whose name is a getter, a setter or a method of the component — the component's own
|
|
1749
|
+
> surface wins. Each of those shapes is reported once as `wcs/mount-own-key-shadow`; rename one side.
|
|
1720
1750
|
>
|
|
1721
1751
|
> **`#ro` on a mount is honoured (3.0):** `state#ro: user` or `state.title#ro: doc.title`
|
|
1722
1752
|
> lets the component read the entry but not write it — `element.state.title = …`, `this.title = …`
|
|
@@ -1783,7 +1813,7 @@ customElements.define("my-component", MyComponent);
|
|
|
1783
1813
|
- `<wcs-state>` with `bind-component` must be a **direct child** of the component element (top-level)
|
|
1784
1814
|
- The parent element must be a **custom element** (tag name containing a hyphen)
|
|
1785
1815
|
- Light DOM components must be wired from the host (the plain, unwired form was removed in v2)
|
|
1786
|
-
- A **mounted** scope does not execute declaration surfaces: `$watch`, `$
|
|
1816
|
+
- A **mounted** scope does not execute declaration surfaces: `$watch`, `$stream` and `$scan` are ignored there with a one-time warning, and `$recursion` / `**` getters are rejected. Declare them on the root state — a volume (`<wcs-state mount>`) can host `$watch`, while `$scan` and `$recursion` are root-only. An unwired Shadow DOM child owns an independent tree and can declare all of them
|
|
1787
1817
|
|
|
1788
1818
|
### Loop with Components
|
|
1789
1819
|
|
|
@@ -1840,7 +1870,7 @@ that only passes the array through — running no `for:` of its own — still le
|
|
|
1840
1870
|
from the owning scope reach the rows at the bottom.
|
|
1841
1871
|
|
|
1842
1872
|
A component's author never has to know how deeply it is placed. `$1`, event-handler indexes,
|
|
1843
|
-
`$
|
|
1873
|
+
`$renderedCallback` and `$getAll` all report positions **within the component's own scope**.
|
|
1844
1874
|
|
|
1845
1875
|
## Command Token (Method Binding)
|
|
1846
1876
|
|
|
@@ -1954,9 +1984,12 @@ interface CommandToken {
|
|
|
1954
1984
|
|
|
1955
1985
|
`emit` returns an array of return values from each subscriber (in subscribe order). For `Promise`-returning methods, wrap with `Promise.all(token.emit(...))` to await all of them.
|
|
1956
1986
|
|
|
1987
|
+
**One subscriber throwing does not stop the others.** A token is a fan-out point and the subscribers do not know about each other, so `emit` calls every one of them: a subscriber that throws is reported with `console.error` (naming the token) and leaves `undefined` at its position in the result array, and the remaining subscribers still receive the call. The exception does **not** reach the emitter — the same rule as a state event handler, because the firing path does not wait for the handler and a DOM-event origin has no caller to throw back to. If a caller needs to know, have the subscriber return a value (or a rejected `Promise`) rather than throw. A `Promise`-returning subscriber that rejects is not caught here at all; that is what `Promise.all(token.emit(...))` is for.
|
|
1988
|
+
|
|
1957
1989
|
### Subscription Lifecycle
|
|
1958
1990
|
|
|
1959
|
-
- The subscriber holds the element via `WeakRef`, so
|
|
1991
|
+
- The subscriber holds the element via `WeakRef`, so nothing in the token's subscriber set keeps the element alive **by itself**
|
|
1992
|
+
- **Caveat (current implementation):** the subscriber closure also captures its `binding`, and `binding.node` is that same element — a strong reference that defeats the `WeakRef` above. In practice a removed element is retained until its binding is released, so the `WeakRef` only pays off where the binding is gone but the token still holds the subscriber. Releasing the subscriber when its row or element goes away (rather than relying on the lazy purge below) is the fix; it is not in 3.x because the binding machinery has no element-lifecycle hook to hang it on, and the change belongs with that hook. Treat the lazy purge as the mechanism you can rely on today
|
|
1960
1993
|
- On `emit`, if the WeakRef has been collected or the element is no longer connected (`isConnected === false`), the subscription is purged automatically (lazy purge)
|
|
1961
1994
|
- Disconnecting the owning `<wcs-state>` keeps the token registry, so the subscriptions still receive commands after the root `<wcs-state>` is re-attached (for example when its host moves in the DOM). While it is disconnected, the state cannot be created, so nothing emits through `$command`
|
|
1962
1995
|
|
|
@@ -1982,7 +2015,7 @@ A command token does not have to be emitted from state code. A DOM event binding
|
|
|
1982
2015
|
|
|
1983
2016
|
This is pure wiring: the event endpoint is connected to a command-token endpoint, with no logic in between. The `emit` arguments are passed through exactly like a handler call — the DOM `Event` first, then any enclosing list indexes — so subscribers receive `(event, ...listIndexes)`. Inside a subscriber, pull what you need from the event (`event.target.value`, `event.detail`, …).
|
|
1984
2017
|
|
|
1985
|
-
- The right-hand side must be `$command.<name>` with `<name>` declared in `$commandTokens`. A path that does not resolve to a `CommandToken` (e.g. a typo)
|
|
2018
|
+
- The right-hand side must be `$command.<name>` with `<name>` declared in `$commandTokens`. A path that does not resolve to a `CommandToken` (e.g. a typo) fails at event time. A DOM event cannot be thrown back at, so it is reported once per event with `console.error` rather than propagated (the same landing as a state handler that rejects).
|
|
1986
2019
|
- Modifiers work unchanged: `onclick#prevent: $command.someToken` calls `preventDefault()` before emitting (`#stop` likewise).
|
|
1987
2020
|
- This emits the same token the state emits, so element subscribers wired with `command.<method>: $command.someToken` receive it regardless of who pulled the trigger.
|
|
1988
2021
|
|
|
@@ -2144,20 +2177,22 @@ The next four sections answer four different questions, and the usual mistake is
|
|
|
2144
2177
|
| Declaration | What you declare | Owns a value | Fires | Typical use |
|
|
2145
2178
|
|---|---|---|---|---|
|
|
2146
2179
|
| [Path getter](#path-getters-computed-properties) | What a value **is**, in terms of the current state | No — it is recomputed and cached per address | Lazily, when a demand root reads it | Subtotals, classification, aggregates |
|
|
2147
|
-
| [`$
|
|
2180
|
+
| [`$stream`](#streams-stream) | An async producer, and the value folded **within one run** | Yes — the runtime owns the output | Per chunk; restarts, back to `initial`, when `args` change | Feeds, sockets, continuous observation |
|
|
2148
2181
|
| [`$watch`](#watch-watch) | A reaction to a change | No | Once per batch per changed address, after the scan write | Side effects, "when this becomes true" |
|
|
2149
2182
|
| [`$scan`](#scan-scan) | An accumulation over time, and what resets it | Yes — the runtime owns the output | Once per landing (`from`) or once per event (`on`) | Paging accumulation, history, counters |
|
|
2150
2183
|
|
|
2151
2184
|
Two rules cut most of the confusion:
|
|
2152
2185
|
|
|
2153
|
-
- **`$
|
|
2154
|
-
- **A `$
|
|
2186
|
+
- **`$renderedCallback` is not on this list.** It reports the bindings that were applied, so anything hung on it silently depends on what is rendered. See [Demand roots](#demand-roots--what-makes-a-getter-run).
|
|
2187
|
+
- **A `$stream` fold resets on every restart; a `$scan` does not.** When the value has to survive the restart, or has to count events rather than states, it belongs in `$scan`.
|
|
2155
2188
|
|
|
2156
|
-
## Streams (`$
|
|
2189
|
+
## Streams (`$stream`)
|
|
2157
2190
|
|
|
2158
|
-
|
|
2191
|
+
> **As of 3.2 the canonical declaration key is `$stream`** (requirement B12), singular like `$watch` and `$scan`. The old `$streams` works the same through 3.x and is removed in 4.0. Declaring both fails with `[wcs/declaration-alias]`. The `$streamStatus` / `$streamError` namespaces do not change. On 3.1 or earlier, write `$streams`.
|
|
2159
2192
|
|
|
2160
|
-
|
|
2193
|
+
Command tokens and event tokens carry discrete interactions. **`$stream`** covers the remaining shape: a continuous flow. Declare an async producer (async iterable / async generator / `ReadableStream`) and the framework **folds it into a single reactive property** — each chunk goes through normal path assignment, so bindings, path getters, and `$renderedCallback` react exactly as if you had assigned the value yourself. When a state path read by the `args` function changes, the running producer is aborted and the source is restarted with the new arguments (switchMap-style dependency-driven restart). Streams start eagerly after `$connectedCallback` completes and are aborted when the element disconnects.
|
|
2194
|
+
|
|
2195
|
+
`$renderedCallback` remains binding-driven: a stream declaration alone is not a headless subscription. Its path appears in the callback only when a live DOM binding for that value/status/error is actually applied. To react to a stream's value without rendering it, declare [`$watch`](#watch-watch) on that path; see the [stream reference](docs/streams.md) for the observation contract.
|
|
2161
2196
|
|
|
2162
2197
|
```html
|
|
2163
2198
|
<wcs-state>
|
|
@@ -2165,7 +2200,7 @@ Command tokens and event tokens carry discrete interactions. **`$streams`** cove
|
|
|
2165
2200
|
export default {
|
|
2166
2201
|
prompt: "",
|
|
2167
2202
|
|
|
2168
|
-
$
|
|
2203
|
+
$stream: {
|
|
2169
2204
|
// Full form: accumulate an LLM token stream
|
|
2170
2205
|
tokens: {
|
|
2171
2206
|
args: (state) => state.prompt, // dependencies are captured here, and only here
|
|
@@ -2204,7 +2239,7 @@ On error the property keeps its last folded value and the error lands in `$strea
|
|
|
2204
2239
|
**Bridging an event API** — most real sources are callback-shaped (`EventSource`, `WebSocket`, DOM events), not async iterables. Wrap them in a standard `ReadableStream`: enqueue in `start`, release the resource in `cancel`. You never touch the `AbortSignal` — on restart/dispose the runtime cancels its reader, which force-unwinds a parked read and runs your `cancel()`:
|
|
2205
2240
|
|
|
2206
2241
|
```js
|
|
2207
|
-
$
|
|
2242
|
+
$stream: {
|
|
2208
2243
|
metrics: {
|
|
2209
2244
|
args: (state) => ({ host: state.host }),
|
|
2210
2245
|
source: ({ host }) => {
|
|
@@ -2230,6 +2265,7 @@ Key rules:
|
|
|
2230
2265
|
- **Bounded fold** — demand never flows back to the producer (backpressure is deliberately abandoned). For infinite / long-lived streams use a bounded fold — latest, count, last-N (`(acc, chunk) => [...acc.slice(-99), chunk]`), windowed aggregates. Raw accumulation of every chunk is for finite streams only.
|
|
2231
2266
|
- **`args` is synchronous** — returning a Promise is an error, and wildcard reads inside `args` are rejected.
|
|
2232
2267
|
- **No self-dependency, no mutual cycles** — `args` reading the stream's own value or status raises an error. Mutual cycles between two streams (A's `args` reads B's value and vice versa) are not detected and restart forever — do not build them. One-way chains (A's value feeding B's `args`) are legitimate.
|
|
2268
|
+
- **The runtime owns the property** — an entry name that collides with a getter, a setter or a method declared on the state raises at declaration, the same as a `$scan` output. (Without that check the method would be silently overwritten by the start-time reset to `initial`.)
|
|
2233
2269
|
- **SSR does not start streams** — on the server the declaration is parsed and the property is materialized with `initial`, but no source runs; the client starts streams as usual.
|
|
2234
2270
|
|
|
2235
2271
|
See [docs/streams.md](docs/streams.md) for the full contract — lifecycle and ownership, restart semantics, flush granularity, and the out-of-scope list.
|
|
@@ -2244,9 +2280,9 @@ There are exactly **three** demand roots:
|
|
|
2244
2280
|
|---|---|---|
|
|
2245
2281
|
| **A live DOM binding** | `data-wcs` / mustache / comment bindings | **Yes** — remove the element and the demand goes with it |
|
|
2246
2282
|
| **A `$watch` declaration** | the state | No (headless) |
|
|
2247
|
-
| **A `$
|
|
2283
|
+
| **A `$stream` `args` function** | the state | No (evaluated on start and on every restart) |
|
|
2248
2284
|
|
|
2249
|
-
**`$
|
|
2285
|
+
**`$renderedCallback` is not a root.** It reports what the bindings did; it does not create demand.
|
|
2250
2286
|
|
|
2251
2287
|
### Rendering can change program semantics
|
|
2252
2288
|
|
|
@@ -2258,27 +2294,27 @@ Because the first root lives in the DOM, **an element you think of as display-on
|
|
|
2258
2294
|
```
|
|
2259
2295
|
|
|
2260
2296
|
```javascript
|
|
2261
|
-
// $
|
|
2297
|
+
// $renderedCallback is binding-driven — delete that <b> and the path stops
|
|
2262
2298
|
// appearing in `paths`, so the feed silently stops committing.
|
|
2263
|
-
$
|
|
2299
|
+
$renderedCallback(paths) {
|
|
2264
2300
|
if (!paths.includes("$streamStatus.pageResult")) return;
|
|
2265
2301
|
this.items = this.items.concat(this.pageResult.items);
|
|
2266
2302
|
}
|
|
2267
2303
|
```
|
|
2268
2304
|
|
|
2269
|
-
**The rule:** logic that must not depend on what is rendered belongs on a `$watch`, a `$scan`, or a `$
|
|
2305
|
+
**The rule:** logic that must not depend on what is rendered belongs on a `$watch`, a `$scan`, or a `$stream` `args`. Keep `$renderedCallback` for "follow what was drawn".
|
|
2270
2306
|
|
|
2271
|
-
That example now accumulates its feed with `$scan` (and re-arms the sentinel from a `$watch`), and the `<b>` is display-only again. This shape — `$
|
|
2307
|
+
That example now accumulates its feed with `$scan` (and re-arms the sentinel from a `$watch`), and the `<b>` is display-only again. This shape — `$renderedCallback` testing a path that is not bound anywhere — is detected statically as **`wcs/updated-callback-unbound`**.
|
|
2272
2308
|
|
|
2273
2309
|
### The limitation that remains
|
|
2274
2310
|
|
|
2275
|
-
Demand still comes from three separate places. **To know whether a getter is evaluated you have to inspect all three — every binding on the page, every `$watch`, and every `$
|
|
2311
|
+
Demand still comes from three separate places. **To know whether a getter is evaluated you have to inspect all three — every binding on the page, every `$watch`, and every `$stream` `args` — and reading the getter's definition will not tell you.** The linter and the DevTools wiring-coverage view exist to make a machine do that cross-check.
|
|
2276
2312
|
|
|
2277
2313
|
Note that a scalar getter named in `$watch` becomes **eager** (evaluated once at connect, then at the end of every batch touching its dependencies). Wildcard row getters do not become eager, because the first evaluation would walk the whole list.
|
|
2278
2314
|
|
|
2279
2315
|
## Watch (`$watch`)
|
|
2280
2316
|
|
|
2281
|
-
`$
|
|
2317
|
+
`$renderedCallback` is **binding-driven**: it reports the paths whose live DOM bindings were actually applied in that update, so a value you never render is invisible to it. **`$watch`** is the headless counterpart — it fires on state changes whether or not anything on the page is bound to the path. (One exception, spelled out below: a *wildcard* row path needs `$listKeys` to work headlessly.)
|
|
2282
2318
|
|
|
2283
2319
|
```html
|
|
2284
2320
|
<wcs-state>
|
|
@@ -2323,11 +2359,11 @@ Firing order is defined in three layers, and only the middle one is yours to ste
|
|
|
2323
2359
|
|
|
2324
2360
|
| Layer | Order | Your control |
|
|
2325
2361
|
|---|---|---|
|
|
2326
|
-
| Mechanisms | `$
|
|
2362
|
+
| Mechanisms | `$renderedCallback` → `$scan` → `$watch` → `$stream` restart | fixed |
|
|
2327
2363
|
| Between handlers | declaration order in `$watch` | **reorder the declarations** |
|
|
2328
2364
|
| Between rows of one path | ascending `indexes` | fixed |
|
|
2329
2365
|
|
|
2330
|
-
**The one thing that moves the mechanism layer** is a `<wcs-view-transition>` that accepts the `state` participant. Binding application — and with it `$
|
|
2366
|
+
**The one thing that moves the mechanism layer** is a `<wcs-view-transition>` that accepts the `state` participant. Binding application — and with it `$renderedCallback` — then lands on a frame, while `$scan`, `$watch` and the `$stream` 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 `$scan` → `$watch` → `$stream` restart → `$renderedCallback`. 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.
|
|
2331
2367
|
|
|
2332
2368
|
Key rules:
|
|
2333
2369
|
|
|
@@ -2336,14 +2372,14 @@ Key rules:
|
|
|
2336
2372
|
- **Rows follow the list as it stands at the drain** — a row written and then removed, replaced or cut off in the same job does not fire, a row that only moved into another position does not fire, and each position fires at most once. Replacing a nested list fires for every row of the new array.
|
|
2337
2373
|
- **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.
|
|
2338
2374
|
- **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.
|
|
2339
|
-
- **Handler exceptions are isolated** — a throw is reported to the console and the remaining watches (and stream restarts) still run. This differs from `$connectedCallback` / `$
|
|
2375
|
+
- **Handler exceptions are isolated** — a throw is reported to the console and the remaining watches (and stream restarts) still run. This differs from `$connectedCallback` / `$renderedCallback`, which fail loudly.
|
|
2340
2376
|
- **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.
|
|
2341
|
-
- **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` / `$
|
|
2377
|
+
- **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` / `$renderedCallback`). This applies to `$stream` too. A plain (unwired Shadow) child owns an independent tree and can declare it.
|
|
2342
2378
|
- **SSR does not run watches** — handler side effects would otherwise execute on both server and client.
|
|
2343
2379
|
|
|
2344
2380
|
## Scan (`$scan`)
|
|
2345
2381
|
|
|
2346
|
-
`$
|
|
2382
|
+
`$stream` folds *within* one run — every restart resets the value to `initial` — and `$watch` owns no value. **`$scan`** declares the value that has to outlive both: an accumulation over time, with an owner, a firing unit and a reset condition.
|
|
2347
2383
|
|
|
2348
2384
|
```html
|
|
2349
2385
|
<wcs-state>
|
|
@@ -2352,7 +2388,7 @@ Key rules:
|
|
|
2352
2388
|
page: 1,
|
|
2353
2389
|
host: "a",
|
|
2354
2390
|
$eventTokens: ["message"],
|
|
2355
|
-
$
|
|
2391
|
+
$stream: {
|
|
2356
2392
|
pageResult: { args: (s) => s.page, source: loadPage },
|
|
2357
2393
|
},
|
|
2358
2394
|
$scan: {
|
|
@@ -2388,7 +2424,7 @@ Key rules:
|
|
|
2388
2424
|
| `fold` | Required. `from`: `(acc, cur, prev, ...indexes) => next`. `on`: `(acc, event, ...indexes) => next`. Synchronous, called without `this`, returns a new value. Returning `acc` itself writes nothing. |
|
|
2389
2425
|
| `resetOn` | Optional array of plain state paths. When one of them is written, the output returns to `initial`: a `from` scan skips that batch's fold, and an `on` scan folds any event that comes after the write into `initial`. A path under `from` raises; an ancestor of `from` is allowed (start over when the parent is replaced). An object path resets only when that object itself is written, not on writes to its children — list the leaf paths or use a nonce. |
|
|
2390
2426
|
|
|
2391
|
-
**The runtime owns the output**, like a `$
|
|
2427
|
+
**The runtime owns the output**, like a `$stream` value. It is materialized from `initial` when the state does not already have that property (plain data is copied, so writing a child path in the plain part of the output never changes the declared `initial`; class instances, frozen values and other non-plain values stay shared with it), and you bind it like any other path. It survives stream restarts, disconnect and reconnect, and a re-set of the same object; a re-set with a new declaration rebuilds the scan. An output name that collides with a getter, a setter, a method or a `$stream` entry raises.
|
|
2392
2428
|
|
|
2393
2429
|
How the two sources fire:
|
|
2394
2430
|
|
|
@@ -2403,8 +2439,8 @@ Key rules:
|
|
|
2403
2439
|
- **Never fold a getter.** A getter re-evaluates whenever its inputs change, so a fold over it would count re-evaluations, not events. A getter as `from` or `resetOn` — or an expansion of a `$recursion` `**` getter such as `nodes.*.total` as `from` — raises at declaration (`wcs/scan-source-computed`).
|
|
2404
2440
|
- **One fold per landing, not per page.** A retry after the page is `done`, or reconnecting the page, lands the same page again. When that matters, keep an idempotency key in the fold — the `pages` list above.
|
|
2405
2441
|
- **Do not derive a stream's `args` from its own scan output.** A getter over `feed` — or over another scan that folds `feed` — read by `pageResult`'s `args` would restart the stream on its own result, so the runtime raises `wcs/scan-feedback-loop`. Advance the cursor from an event instead. A chunk that lands in the same batch as its stream's restart belongs to the aborted run and is not folded.
|
|
2406
|
-
- **Receive element events through `on`.** A `from` path sees every write to that path, including a bound element's initial sync and a whole-parent write (which arrives with `prev === undefined`). `prev` follows `$watch`'s ledger, so it is also `undefined` for a write made inside the `$scan` / `$watch` listener — by a `$watch` handler, or by another scan whose output is the `from`. The ledger is cleared at the end of that listener, so the `$
|
|
2407
|
-
- **Keep folds bounded.** An infinite source must fold into a bounded value (the last N, a count), exactly as with `$
|
|
2442
|
+
- **Receive element events through `on`.** A `from` path sees every write to that path, including a bound element's initial sync and a whole-parent write (which arrives with `prev === undefined`). `prev` follows `$watch`'s ledger, so it is also `undefined` for a write made inside the `$scan` / `$watch` listener — by a `$watch` handler, or by another scan whose output is the `from`. The ledger is cleared at the end of that listener, so the `$stream` restart that runs after it in the same drain keeps `prev`.
|
|
2443
|
+
- **Keep folds bounded.** An infinite source must fold into a bounded value (the last N, a count), exactly as with `$stream`.
|
|
2408
2444
|
- **Errors are isolated.** A throw, a returned Promise or a value that cannot be read is reported to the console and DevTools and writes nothing (an unreadable row of a wildcard `from` is skipped alone, and row landings are narrowed to one per list position); the other scans, watches and stream restarts still run.
|
|
2409
2445
|
- **`$watch` runs after the scan write.** A `$watch` handler in the same drain reads the output as folded, and a value it writes to the output stays. When the `from` source is written again before the output's landing drains — by a `$watch` handler in that drain, say — both land in one batch: a `$watch` on the output then gets the landed value in `prev`, sees `cur` one step ahead, and can fire again with the same value in the next batch, so make it tolerant of a repeated value. Clear an accumulation from a user action with a nonce read by `resetOn`.
|
|
2410
2446
|
- **Root only.** A volume (`mount=`) refuses `$scan`, and a mounted `bind-component` scope ignores it with a one-time warning. Under SSR, `from` does not fold; the output is still materialized.
|
|
@@ -2546,7 +2582,7 @@ Both declarations are validated when the component is defined, with the same str
|
|
|
2546
2582
|
- an entry that is not a non-empty string
|
|
2547
2583
|
- an entry starting with `$` (internal properties are never exposed on the component prototype)
|
|
2548
2584
|
- a duplicated entry — this one used to fail silently: a duplicate name makes the whole `wcBindable` declaration unreadable, so the element would quietly stop being two-way bindable
|
|
2549
|
-
- an entry that does not exist on the state (own properties and the prototype chain are both searched; `$
|
|
2585
|
+
- an entry that does not exist on the state (own properties and the prototype chain are both searched; `$stream` names count as existing since their value properties are materialized per instance)
|
|
2550
2586
|
- a method listed in `$bindables`, or a value property listed in `$commands`
|
|
2551
2587
|
|
|
2552
2588
|
### Driving a DCC Method
|
|
@@ -2606,7 +2642,7 @@ Properties prefixed with `$` are internal and not exposed on the component proto
|
|
|
2606
2642
|
| `$commands` | Declares invocable methods |
|
|
2607
2643
|
| `$connectedCallback` | Lifecycle hook (runs on each instance) |
|
|
2608
2644
|
| `$disconnectedCallback` | Cleanup hook |
|
|
2609
|
-
| `$
|
|
2645
|
+
| `$renderedCallback` | Called after state mutations |
|
|
2610
2646
|
|
|
2611
2647
|
## SVG Support
|
|
2612
2648
|
|
|
@@ -2622,7 +2658,9 @@ All bindings work inside `<svg>` elements. Use `attr.*` for SVG attributes:
|
|
|
2622
2658
|
|
|
2623
2659
|
## Lifecycle Hooks
|
|
2624
2660
|
|
|
2625
|
-
State objects can define `$connectedCallback`, `$disconnectedCallback`, `$
|
|
2661
|
+
State objects can define `$connectedCallback`, `$disconnectedCallback`, `$renderedCallback`, and `$errorCallback` for initialization, cleanup, update, and binding-failure handling.
|
|
2662
|
+
|
|
2663
|
+
> **As of 3.2 the canonical name of `$updatedCallback` is `$renderedCallback`** (requirement B12). The hook receives updates of **applied bindings**, not every state update (use `$watch` for those), and the new name says so. The old name works the same through 3.x and is removed in 4.0. Declaring both fails with `[wcs/declaration-alias]`. On 3.1 or earlier, write `$updatedCallback`.
|
|
2626
2664
|
|
|
2627
2665
|
```html
|
|
2628
2666
|
<wcs-state>
|
|
@@ -2651,15 +2689,15 @@ State objects can define `$connectedCallback`, `$disconnectedCallback`, `$update
|
|
|
2651
2689
|
|---|---|---|
|
|
2652
2690
|
| `$connectedCallback` | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) |
|
|
2653
2691
|
| `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
|
|
2654
|
-
| `$
|
|
2655
|
-
| `$errorCallback(error, info)` | After a drain in which a binding failed to apply — once per failed binding, after `$
|
|
2692
|
+
| `$renderedCallback(paths, indexesListByPath)` | After updates are applied to live bindings | Yes (not awaited) |
|
|
2693
|
+
| `$errorCallback(error, info)` | After a drain in which a binding failed to apply — once per failed binding, after `$renderedCallback` | Yes (not awaited) |
|
|
2656
2694
|
|
|
2657
2695
|
All hooks except `$disconnectedCallback` support `async` — you can use `async/await` in any of them. Since the reactive proxy detects every property assignment as a change, standard `async/await` with direct property updates is sufficient for asynchronous operations — loading flags, fetched data, and error messages are all just property assignments, without requiring additional abstractions for async state management.
|
|
2658
2696
|
|
|
2659
2697
|
- `this` inside hooks is the state proxy with full read/write access
|
|
2660
2698
|
- `$connectedCallback` is called **every time** the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
|
|
2661
2699
|
- `$disconnectedCallback` is called synchronously — use it for cleanup such as clearing timers, removing event listeners, or releasing resources
|
|
2662
|
-
- `$
|
|
2700
|
+
- `$renderedCallback(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. Marker paths of mounted components (`#m…`) never appear in `paths` — a component's private keys stay private (DevTools shows them in its overlays view). Can be `async`, but the return value is not awaited
|
|
2663
2701
|
- `$errorCallback(error, info)` is the in-page **error boundary** for bindings. When applying a binding throws — a path getter or filter threw, a structural directive failed — the failure is isolated (the rest of the batch still applies, and neither the value nor the DOM is rolled back) and, without this hook, reported with `console.error`. Declare the hook and the report comes to you instead: `error` is what was thrown, `info` is `{ path, bindingType, node }` identifying the binding (`path` as written in `data-wcs`, wildcards intact). `this` is the writable state proxy, so the usual shape is to write the message into state and render it like anything else:
|
|
2664
2702
|
|
|
2665
2703
|
```js
|
|
@@ -2672,7 +2710,7 @@ All hooks except `$disconnectedCallback` support `async` — you can use `async/
|
|
|
2672
2710
|
};
|
|
2673
2711
|
```
|
|
2674
2712
|
|
|
2675
|
-
The hook runs after the batch (after `$
|
|
2713
|
+
The hook runs after the batch (after `$renderedCallback`), is not awaited, and an exception thrown inside it is reported to the console without breaking the drain. DevTools still receives every failure as `state:binding-apply-error` whether or not the hook exists. Root-only: a volume (`<wcs-state mount>`) declaring it is ignored. It does not cover `$watch` handlers (isolated and reported separately) or errors thrown by `$connectedCallback` / `$renderedCallback` (those fail loudly).
|
|
2676
2714
|
- In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
|
|
2677
2715
|
|
|
2678
2716
|
## Transition animations
|
|
@@ -2695,8 +2733,8 @@ li {
|
|
|
2695
2733
|
|
|
2696
2734
|
Two consequences to know while that tag accepts the `state` participant:
|
|
2697
2735
|
|
|
2698
|
-
- 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. `$
|
|
2699
|
-
- Because `$scan`, `$watch` and the `$
|
|
2736
|
+
- 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. `$renderedCallback` still fires immediately after the bindings are applied — its *position* is unchanged, but it moves a frame later along with them.
|
|
2737
|
+
- Because `$scan`, `$watch` and the `$stream` restart stay on the original microtask, they now run **before** `$renderedCallback` instead of after it.
|
|
2700
2738
|
|
|
2701
2739
|
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.
|
|
2702
2740
|
|
|
@@ -2740,7 +2778,7 @@ Anything that follows mechanically from the path string is reported at runtime a
|
|
|
2740
2778
|
| `wcs/getter-cycle` | Path getters must not form a dependency cycle. At runtime this is the address stack revisiting an address it already holds | Break the cycle |
|
|
2741
2779
|
| `wcs/getter-depth-exceeded` | Getter evaluation nests deeper than the engine evaluates in one pass (128 frames), with no address visited twice — the data is simply that deep | Aggregate in fewer levels, or flatten the tree |
|
|
2742
2780
|
| `wcs/index-param-range` | `$N` must name an existing wildcard level: `$1` through `$128`, no leading zeros | Use a level that exists |
|
|
2743
|
-
| `wcs/recursion-unsupported` | `**` reached something that does not interpret it — markup, a `$watch` or `$listKeys` key, `$resolve` / `$postUpdate` / `$
|
|
2781
|
+
| `wcs/recursion-unsupported` | `**` reached something that does not interpret it — markup, a `$watch` or `$listKeys` key, `$resolve` / `$postUpdate` / `$dependOn`, an assignment — or the state declares no `$recursion` at all | Use a concrete path, or declare the anchor |
|
|
2744
2782
|
| `wcs/recursion-declaration-invalid` | The `$recursion` declaration or a `**` getter key has a shape this version refuses: an anchor or repeat that is not a list element or carries an index segment (`"nodes.0.items.*"`), more than one anchor, a `**` key that is not a getter or has a setter, `get "nodes.**"`, a getter that names the structure, two getters expanding to one concrete path, a concrete getter with the same name as an expansion. The linter reports it first; at runtime it throws when the declaration is read | Fix the declaration as the message says |
|
|
2745
2783
|
| `wcs/recursion-anchor` | A `**` path that does not match the one declared anchor (this version takes exactly one self-recursive anchor per state), or whose suffix after `**` is not well-formed — an empty segment (`nodes.**.`, `nodes.**..x`) or a bare `*` right after `**` (`nodes.**.*`) | Spell the anchor as declared, and a real path after it |
|
|
2746
2784
|
| `wcs/recursion-context` | A **bound** `**` was read where there is no depth to bind to — the top level, or a getter outside the anchor | Read it from a recursive or row getter, or pass `[]` to union every depth |
|
|
@@ -2766,7 +2804,7 @@ this.$getAll("matrix.*.*", [row]);
|
|
|
2766
2804
|
|
|
2767
2805
|
### One failing binding is confined to that binding
|
|
2768
2806
|
|
|
2769
|
-
If applying a binding throws, the rest of that batch, `$
|
|
2807
|
+
If applying a binding throws, the rest of that batch, `$renderedCallback`, `$watch`, and `$stream` restarts all still run. The failure is not swallowed — it goes to `console.error` and to DevTools (`state:binding-apply-error`):
|
|
2770
2808
|
|
|
2771
2809
|
```
|
|
2772
2810
|
[@wcstack/state] binding "text: items.*.label" failed to apply; the rest of this batch continues.
|
|
@@ -3064,7 +3102,7 @@ Subpath entries for tooling: `@wcstack/state/parser` (the `data-wcs` parser as a
|
|
|
3064
3102
|
| `setterPaths` | Set of paths defined as setters |
|
|
3065
3103
|
| `createState(mutability, callback)` | Create a state proxy (`"readonly"` or `"writable"`) |
|
|
3066
3104
|
| `createStateAsync(mutability, callback)` | Async version of `createState` |
|
|
3067
|
-
| `setInitialState(state)` | Set state programmatically. Before initialization it supplies the initial state. On an initialized element it replaces the whole state and re-applies every established binding to the new state before it returns (a detached element re-applies them when it reconnects); a binding whose path the new state no longer has reports a failed apply instead of keeping the old text. A re-set is not a write: it fires no `$watch` handler and no `$
|
|
3105
|
+
| `setInitialState(state)` | Set state programmatically. Before initialization it supplies the initial state. On an initialized element it replaces the whole state and re-applies every established binding to the new state before it returns (a detached element re-applies them when it reconnects); a binding whose path the new state no longer has reports a failed apply instead of keeping the old text. A re-set is not a write: it fires no `$watch` handler and no `$renderedCallback`. Lists are matched by array identity, so pass a new array when a list's length changed — re-setting with the same array instance after pushing to or splicing it in place is not supported. Throws on a loaded volume (`<wcs-state mount="…">` — its data was copied into the root tree, so write the paths under the mount path on the root instead), on a tree with grafted volumes or mounted components, and on an element that already failed to initialize — such an element cannot be re-armed; remove it and create a new one |
|
|
3068
3106
|
| `nextVersion()` | Increment and return version number |
|
|
3069
3107
|
|
|
3070
3108
|
## Architecture
|