@wcstack/state 3.5.4 โ 4.0.0-rc.2
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 +669 -428
- package/README.md +667 -426
- package/dist/auto.min.js +1 -2
- package/dist/define.d.ts +4 -8
- package/dist/define.js +1 -1
- package/dist/index.d.ts +139 -1483
- package/dist/index.esm.js +1 -2
- package/dist/manifest.d.ts +18 -59
- package/dist/manifest.esm.js +542 -1202
- package/dist/parser.d.ts +49 -103
- package/dist/parser.esm.js +662 -831
- package/dist/split/auto.js +1 -0
- package/dist/split/chunks/chunk-732CB4QN.js +1 -0
- package/dist/split/chunks/chunk-7PN5YNZZ.js +1 -0
- package/dist/split/chunks/chunk-FF6KHTYS.js +1 -0
- package/dist/split/chunks/chunk-HFKGUDDJ.js +1 -0
- package/dist/split/chunks/chunk-PKDBVBAP.js +1 -0
- package/dist/split/chunks/chunk-QI7P7YPP.js +1 -0
- package/dist/split/chunks/chunk-QQH6N5CJ.js +1 -0
- package/dist/split/chunks/chunk-UM3O6QXD.js +1 -0
- package/dist/split/chunks/chunk-WEL3YSG7.js +1 -0
- package/dist/split/chunks/chunk-WWJNAZVP.js +1 -0
- package/dist/split/chunks/chunk-YOPPVWPQ.js +1 -0
- package/dist/split/core.d.ts +114 -1168
- package/dist/split/core.js +1 -2
- package/dist/split/features/devtools.d.ts +23 -3
- package/dist/split/features/devtools.js +1 -2
- package/dist/split/features/diagnostics.d.ts +27 -5
- package/dist/split/features/diagnostics.js +1 -2
- package/dist/split/features/formats.d.ts +21 -5
- package/dist/split/features/formats.js +1 -2
- package/dist/split/features/list-keys.d.ts +24 -0
- package/dist/split/features/list-keys.js +1 -0
- package/dist/split/features/recursion.d.ts +23 -3
- package/dist/split/features/recursion.js +1 -2
- package/dist/split/features/scopes.d.ts +25 -4
- package/dist/split/features/scopes.js +1 -2
- package/dist/split/features/ssr.d.ts +23 -3
- package/dist/split/features/ssr.js +1 -2
- package/dist/split/features/temporal.d.ts +19 -4
- package/dist/split/features/temporal.js +1 -2
- package/dist/wcs-manifest.json +43 -49
- package/package.json +15 -17
- package/dist/auto.min.js.map +0 -1
- package/dist/index.esm.js.map +0 -1
- package/dist/split/chunks/binder.js +0 -2
- package/dist/split/chunks/binder.js.map +0 -1
- package/dist/split/chunks/checkDependency.js +0 -2
- package/dist/split/chunks/checkDependency.js.map +0 -1
- package/dist/split/chunks/declarationAliases.js +0 -2
- package/dist/split/chunks/declarationAliases.js.map +0 -1
- package/dist/split/chunks/declarationHooks.js +0 -2
- package/dist/split/chunks/declarationHooks.js.map +0 -1
- package/dist/split/chunks/diagnosticsHooks.js +0 -2
- package/dist/split/chunks/diagnosticsHooks.js.map +0 -1
- package/dist/split/chunks/errorGuidance.js +0 -2
- package/dist/split/chunks/errorGuidance.js.map +0 -1
- package/dist/split/chunks/featureBridge.js +0 -2
- package/dist/split/chunks/featureBridge.js.map +0 -1
- package/dist/split/chunks/features.d.ts +0 -29
- package/dist/split/chunks/filterRegistry.js +0 -2
- package/dist/split/chunks/filterRegistry.js.map +0 -1
- package/dist/split/chunks/getAllPropertyDescriptors.js +0 -2
- package/dist/split/chunks/getAllPropertyDescriptors.js.map +0 -1
- package/dist/split/chunks/lifecycleHooks.js +0 -2
- package/dist/split/chunks/lifecycleHooks.js.map +0 -1
- package/dist/split/chunks/pathDiagnostics.js +0 -2
- package/dist/split/chunks/pathDiagnostics.js.map +0 -1
- package/dist/split/chunks/registerComponents.js +0 -2
- package/dist/split/chunks/registerComponents.js.map +0 -1
- package/dist/split/chunks/setAll.js +0 -2
- package/dist/split/chunks/setAll.js.map +0 -1
- package/dist/split/chunks/version.js +0 -2
- package/dist/split/chunks/version.js.map +0 -1
- package/dist/split/core.js.map +0 -1
- package/dist/split/features/devtools.js.map +0 -1
- package/dist/split/features/diagnostics.js.map +0 -1
- package/dist/split/features/formats.js.map +0 -1
- package/dist/split/features/recursion.js.map +0 -1
- package/dist/split/features/scopes.js.map +0 -1
- package/dist/split/features/ssr.js.map +0 -1
- package/dist/split/features/temporal.js.map +0 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> ๐ค **AI coding agents**: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository [README](https://github.com/wcstack/wcstack#readme) and [AGENTS.md](https://github.com/wcstack/wcstack/blob/main/AGENTS.md), then use the [wcstack-app skill](https://github.com/wcstack/wcstack-skill).
|
|
4
4
|
|
|
5
|
-
> **
|
|
5
|
+
> **This README describes 4.0.** Coming from 3.x? Read [Upgrading from 3.x](#upgrading-from-3x) and the [3.x โ 4.0 migration guide](https://github.com/wcstack/wcstack/blob/main/docs/migration-v4.md).
|
|
6
6
|
|
|
7
7
|
**This is not another convenient frontend framework. It brings a lineage established outside frontend development โ the one where a path string is the contract between view and model โ onto web standards.**
|
|
8
8
|
|
|
@@ -43,7 +43,7 @@ The nearer relatives are the **attribute-directive, no-build libraries** โ Alp
|
|
|
43
43
|
|
|
44
44
|
**Choose it** for HTML-first pages: server-rendered or static markup with reactive parts, a page composed from custom elements, anywhere "read the HTML and know every data dependency" matters and a build step is a cost rather than a given.
|
|
45
45
|
|
|
46
|
-
**Do not choose it** when the team already lives inside a component framework โ use the I/O nodes through the [framework adapters](../../docs/framework-adapter-integration.md) instead; when the hot path is a very large keyed list โ [Performance](#performance)
|
|
46
|
+
**Do not choose it** when the team already lives inside a component framework โ use the I/O nodes through the [framework adapters](../../docs/framework-adapter-integration.md) instead; when the hot path is a very large keyed list and create / append time decides โ measure it ([Performance](#performance)), and consider [`@wcstack/signals`](../signals/), which interoperates with this package; when templates need inline expressions โ deliberately absent; or when the template must be type-checked by the compiler rather than by tooling โ paths are strings, and `@wcstack/typescript` narrows that gap without closing it.
|
|
47
47
|
|
|
48
48
|
On those axes the comparison is concrete: the [Performance](#performance) section below is one, and the drivers under `e2e/bench/` regenerate it on your own hardware.
|
|
49
49
|
|
|
@@ -56,7 +56,7 @@ The premise โ *a path string is the whole contract between a view and a model*
|
|
|
56
56
|
| **Spreadsheets** (VisiCalc, 1979) | An address, a formula declaring what a cell **is**, a dependency graph, lazy recomputation โ and no update code anywhere | Names instead of grid coordinates, and one formula per *shape* rather than per cell: `get "cart.items.*.subtotal"()` is not filled down into the rows; the wildcard is the definition |
|
|
57
57
|
| **Cocoa Bindings / KVCโKVO** (NeXT's EOF, 1994; Mac OS X 10.3, 2003) | Key paths (`person.address.street`), a binding triple of target + key path + value transformer, and collection operators that aggregate along a path (`@sum.items.price`) | The triple lives in the markup instead of a nib or a `bind:toObject:withKeyPath:` call, so it can be grepped, linted and diffed. Change detection is an ES Proxy over plain objects rather than KVC compliance |
|
|
58
58
|
| **XForms** (W3C Recommendation, 2003) | Model / instance / view separation, `ref` paths into the instance, and `<bind calculate="โฆ">` โ a computed value declared **at a path**, the direct ancestor of a path getter | The path is an address and nothing else: the computation is a JavaScript getter on the state, not XPath inside an attribute. And it runs in a stock browser, with no XForms processor |
|
|
59
|
-
| **WPF / XAML** (2006) | `{Binding Path=User.Name, Mode=TwoWay}`; a `DataContext` that re-roots a whole subtree; `UpdateSourceTrigger` choosing when the source is written; `IValueConverter` between the ends | One state tree per root, rather than a context inherited down the visual tree with `RelativeSource` / `ElementName` escapes โ `state: user` is that re-rooting, written in the host's HTML. Converters are a closed set of
|
|
59
|
+
| **WPF / XAML** (2006) | `{Binding Path=User.Name, Mode=TwoWay}`; a `DataContext` that re-roots a whole subtree; `UpdateSourceTrigger` choosing when the source is written; `IValueConverter` between the ends | One state tree per root, rather than a context inherited down the visual tree with `RelativeSource` / `ElementName` escapes โ `state: user` is that re-rooting, written in the host's HTML. Converters are a closed set of 47 filters, not classes you register, and nothing is compiled |
|
|
60
60
|
| **Android Data Binding** (2015) | The path in the layout file itself โ `android:text="@{user.name}"`, `@={}` for two-way | No build step and no generated binding class, and no expressions inside the attribute |
|
|
61
61
|
| **SCADA / HMI tag binding** (industrial, decades) | Widget properties wired to tag paths (`Line1/Tank/Level`) by configuration alone, and *indirect* bindings that parameterize the path (`Folder/Tag_{1}`) so one screen drives many devices | The tree carries derived values, lists and mounted components, not a flat namespace of scalars; the parameter is a loop's wildcard, resolved by the row the binding sits in rather than assigned from a dropdown |
|
|
62
62
|
|
|
@@ -90,7 +90,7 @@ This separates UI and state with **no JavaScript intermediary**. You can:
|
|
|
90
90
|
|
|
91
91
|
- 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
|
|
92
92
|
- Refactor state structure and only update path strings
|
|
93
|
-
- Read the HTML and know every binding; the dependencies that are not in the HTML (`$watch`, `$stream
|
|
93
|
+
- Read the HTML and know every binding; the dependencies that are not in the HTML (`$watch`, `$stream`) are all declared in one place, the state
|
|
94
94
|
|
|
95
95
|
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.
|
|
96
96
|
|
|
@@ -124,12 +124,13 @@ Every row is a section of this README. Unless it appears under [Where the neighb
|
|
|
124
124
|
|
|
125
125
|
| Area | In one line | Reference |
|
|
126
126
|
|---|---|---|
|
|
127
|
-
| **Path model** | Dot paths address state; `*` is an abstract index, `**` a depth, `$1` / `$2` name the axes | [First principle](#first-principle-path-as-the-universal-contract) ยท [Loop index variables](#loop-index-variables-1-2) |
|
|
128
|
-
| **Binding syntax** | One `data-wcs` attribute carries property / text / class / style / attribute / event bindings; `{{ }}` in text
|
|
127
|
+
| **Path model** | Dot paths address state; `*` is an abstract index, `**` a depth, `$1` / `$2` name the axes | [First principle](#first-principle-path-as-the-universal-contract) ยท [Loop index variables](#loop-index-variables-1-2-) |
|
|
128
|
+
| **Binding syntax** | One `data-wcs` attribute carries property / text / class / style / attribute / event bindings; `{{ }}` and `<!--@@: path-->` in text; the same inside `<svg>` | [Binding syntax](#binding-syntax) ยท [Mustache](#mustache-syntax) ยท [SVG](#svg-support) |
|
|
129
|
+
| **Events** | `on*:` handlers of the common bubbling events are delegated to the root; `#direct` attaches one to its element | [Event handling](#event-handling) |
|
|
129
130
|
| **Structural directives** | `for` and `if` / `elseif` / `else` on `<template>` elements | [Structural directives](#structural-directives) |
|
|
130
131
|
| **Row identity** | Rows diff by reference, so a sort or a filter reuses the DOM; `$listKeys` keeps row DOM and row objects across refetched arrays | [`$listKeys`](#listkeys--identity-for-refetched-rows) |
|
|
131
132
|
| **Forms** | Two-way binding for `input` / `select` / `textarea`, a radio group to one value, a checkbox group to an array, `#ro` / `#onchange` / `#prevent` / `#stop` | [Two-way binding](#two-way-binding) ยท [Modifiers](#modifiers) |
|
|
132
|
-
| **Filters** |
|
|
133
|
+
| **Filters** | 47 built-ins, chainable, locale-aware formatting that reads `<html lang>` | [Filters](#filters) ยท [Locale](#locale) |
|
|
133
134
|
| **Derived state** | Path getters declare virtual properties at any depth from one flat place; they chain, and they take setters | [Path getters](#path-getters-computed-properties) |
|
|
134
135
|
| **Aggregation and bulk write** | `$getAll` / `$setAll` / `$resolve` read and write across `items.*.price` without rebuilding the array | [Proxy APIs](#proxy-apis) |
|
|
135
136
|
| **Recursive paths** | `$recursion` declares where a tree's shape repeats; one `**` getter covers every depth | [Recursive paths](#recursive-paths-recursion) |
|
|
@@ -137,12 +138,14 @@ Every row is a section of this README. Unless it appears under [Where the neighb
|
|
|
137
138
|
| **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) |
|
|
138
139
|
| **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) |
|
|
139
140
|
| **Components** | Two mutually exclusive mechanisms: a JavaScript class with `bind-component`, or HTML-only DCC | [Choosing a mechanism](#choosing-a-component-mechanism) |
|
|
140
|
-
| **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) |
|
|
141
|
+
| **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) |
|
|
141
142
|
| **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) |
|
|
142
|
-
| **Time** | `$stream` folds an async source, `$watch` reacts headlessly
|
|
143
|
+
| **Time** | `$stream` folds an async source, `$watch` reacts headlessly | [Choosing a time mechanism](#choosing-a-time-mechanism) |
|
|
143
144
|
| **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) |
|
|
144
|
-
| **
|
|
145
|
-
| **
|
|
145
|
+
| **Configuration** | `bootstrapState()` sets how the page spells the markup; each state declares its own behavior (`$behavior`) and the add-ons it needs (`$features`) | [Configuration](#configuration) |
|
|
146
|
+
| **Diagnostics** | Unresolved paths, index arity, wildcard rank and getter cycles are reported, under the lint's codes and a message number; one failing binding stays confined, and neither values nor the DOM are rolled back | [Diagnostics](#diagnostics-and-failure-handling) |
|
|
147
|
+
| **Security** | Values are never sanitized and never read as markup; markup you did not write must not reach the page walk | [Security](#security) |
|
|
148
|
+
| **Delivery** | Zero runtime dependencies, no build step, ESM, one CDN `/auto` tag, or split entries with only the add-ons a page uses; no `unsafe-eval`, Trusted Types supported | [Installation](#installation) ยท [docs/csp.md](../../docs/csp.md) |
|
|
146
149
|
|
|
147
150
|
### Where the neighbours come in
|
|
148
151
|
|
|
@@ -154,12 +157,12 @@ Every row is a section of this README. Unless it appears under [Where the neighb
|
|
|
154
157
|
| [`@wcstack/lint`](../lint/) | `npx @wcstack/lint <file>` checks every `data-wcs` in an HTML file before it runs | The diagnostic codes and `getWcsManifest()`, both derived from this implementation โ [Diagnostics](#diagnostics-and-failure-handling) |
|
|
155
158
|
| VS Code extension (`wcstack-intellisense`) | The same diagnostics, plus completion, inside the editor | The same manifest and codes |
|
|
156
159
|
| [`@wcstack/typescript`](../typescript/) | `wcs-schema` carries the types into the HTML validator; `wcs-tsc` type-checks inline state scripts | `defineState()`, `WcsPaths<T>` / `WcsPathValue<T, P>` โ [TypeScript support](#typescript-support) |
|
|
157
|
-
| [`@wcstack/devtools`](../devtools/) | A browser panel over state, wiring and update history | The instrumentation the panel reads |
|
|
160
|
+
| [`@wcstack/devtools`](../devtools/) | A browser panel over state, wiring and update history | The instrumentation the panel reads (hook protocol v2; the `devtools` add-on, included in the full entries) |
|
|
158
161
|
| [`@wcstack/testing`](../testing/) | `mount()` / `settle()` / `fire()` as one import | The bare recipes that need no extra package โ [Testing your page](#testing-your-page) |
|
|
159
162
|
| [`@wcstack/view-transition`](../view-transition/) | Animates list moves, removals and branch swaps through the View Transition API | The transition-runner hand-off; with no arbiter on the page the mutation applies directly โ [Transition animations](#transition-animations) |
|
|
160
|
-
| [`@wcstack/router`](../router/) ยท [`@wcstack/autoloader`](../autoloader/) | Declarative routing; automatic loading of undefined custom elements | Paths a route can write into, and bindings that wait for a late definition |
|
|
161
|
-
| The [I/O nodes](../../README.md#additional-packages) โ `fetch`, `storage`, `ws`, `midi`, โฆ | The platform APIs as elements | The wc-bindable wiring, spread and the token protocols that connect them โ [Spread](#spread-binding) |
|
|
162
|
-
| [`@wcstack/signals`](../signals/) | A different reactive core
|
|
163
|
+
| [`@wcstack/router`](../router/) ยท [`@wcstack/autoloader`](../autoloader/) | Declarative routing; automatic loading of undefined custom elements | Paths a route can write into, the binder that binds route content when it is inserted ([Structural templates in inserted content](#structural-templates-in-inserted-content)), and bindings that wait for a late definition |
|
|
164
|
+
| The [I/O nodes](../../README.md#additional-packages) โ `fetch`, `storage`, `ws`, `midi`, โฆ | The platform APIs as elements | The wc-bindable wiring, spread and the token protocols that connect them โ [Spread](#spread-binding-) |
|
|
165
|
+
| [`@wcstack/signals`](../signals/) | A different reactive core: signals in JavaScript, with async-IO resource adapters | Interop โ both speak wc-bindable, so the I/O nodes and DCCs are shared โ [Performance](#performance) |
|
|
163
166
|
|
|
164
167
|
## Installation
|
|
165
168
|
|
|
@@ -189,15 +192,15 @@ leaves features out, it can compose them instead:
|
|
|
189
192
|
<script type="importmap">
|
|
190
193
|
{
|
|
191
194
|
"imports": {
|
|
192
|
-
"@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@
|
|
193
|
-
"@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@
|
|
194
|
-
"@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@
|
|
195
|
+
"@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0-rc.2/dist/split/core.js",
|
|
196
|
+
"@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0-rc.2/dist/split/features/temporal.js",
|
|
197
|
+
"@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0-rc.2/dist/split/features/scopes.js"
|
|
195
198
|
}
|
|
196
199
|
}
|
|
197
200
|
</script>
|
|
198
201
|
<script type="module">
|
|
199
202
|
import { bootstrapState, installFeatures } from '@wcstack/state/core';
|
|
200
|
-
import temporal from '@wcstack/state/features/temporal'; // $watch / $stream
|
|
203
|
+
import temporal from '@wcstack/state/features/temporal'; // $watch / $stream
|
|
201
204
|
import scopes from '@wcstack/state/features/scopes'; // bind-component, mount=, DCC
|
|
202
205
|
|
|
203
206
|
installFeatures([temporal, scopes]);
|
|
@@ -205,11 +208,12 @@ leaves features out, it can compose them instead:
|
|
|
205
208
|
</script>
|
|
206
209
|
```
|
|
207
210
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
211
|
+
**Install the add-ons before any state is defined** โ call `installFeatures([...])` before
|
|
212
|
+
`bootstrapState()`, as above. A state that declares an add-on's key (`$watch`, `$listKeys`, โฆ) while
|
|
213
|
+
that add-on is not installed fails with `[wcs/feature-not-installed]`, and an engine created before an
|
|
214
|
+
add-on was installed does not gain it later. An add-on installed after `bootstrapState()` still
|
|
215
|
+
defines its own tag (`<wcs-ssr>`) in the registries already bootstrapped, but only the declared order
|
|
216
|
+
has every add-on in place before the first `<wcs-state>` starts.
|
|
213
217
|
|
|
214
218
|
Load the split form from the package's own files โ jsDelivr's plain, version-pinned `/npm/` path as
|
|
215
219
|
above (it does not read `exports`, so name the file under `dist/split/`), or a bundler โ and **never
|
|
@@ -218,27 +222,69 @@ core chunk into each one, so every entry would carry its own engine: a feature w
|
|
|
218
222
|
copy the core never sees, and the page throws `[wcs/feature-not-installed]` in spite of
|
|
219
223
|
`installFeatures`. From the plain files, every entry's relative import resolves to the same chunk URL,
|
|
220
224
|
so the browser evaluates the engine once. Integrity for this form: [docs/sri.md ยง5.1](../../docs/sri.md#51-the-split-entries-of-wcstackstate).
|
|
225
|
+
The file names under `dist/split/chunks/` carry a content hash: if you list chunk files yourself
|
|
226
|
+
(preload links, `integrity` in an import map), take the names from the build you load.
|
|
221
227
|
|
|
222
228
|
| Entry | What it adds |
|
|
223
229
|
|---|---|
|
|
224
|
-
| `@wcstack/state/core` | The binding engine: `data-wcs`, `for` / `if`, path getters,
|
|
225
|
-
| `@wcstack/state/features/temporal` | `$watch`, `$
|
|
230
|
+
| `@wcstack/state/core` | The binding engine: `data-wcs`, `{{ }}` and comment bindings, `for` / `if`, path getters, events, `$command` / `$on`, the 24 logic filters (comparison, arithmetic, conversion, defaults), `bootstrapState`, `installFeatures` |
|
|
231
|
+
| `@wcstack/state/features/temporal` | `$watch`, `$stream` |
|
|
232
|
+
| `@wcstack/state/features/list-keys` | `$listKeys` |
|
|
226
233
|
| `@wcstack/state/features/scopes` | `bind-component`, `mount=` volumes, overlay exports, DCC (`data-wc-definition`) |
|
|
227
234
|
| `@wcstack/state/features/recursion` | `$recursion` and `**` paths |
|
|
228
235
|
| `@wcstack/state/features/ssr` | `enable-ssr`: server rendering and hydration |
|
|
229
|
-
| `@wcstack/state/features/formats` | The formatting filters (`upper`, `date`, `round`, `truncate`, โฆ)
|
|
236
|
+
| `@wcstack/state/features/formats` | The 23 formatting filters (`upper`, `date`, `round`, `truncate`, โฆ) |
|
|
230
237
|
| `@wcstack/state/features/devtools` | The DevTools hook protocol source |
|
|
231
|
-
| `@wcstack/state/features/diagnostics` |
|
|
238
|
+
| `@wcstack/state/features/diagnostics` | The sentences of error messages, with the did-you-mean and the pointer to lint, and the development-time warnings (a bound or `$watch` path that does not resolve, `wcs/default-getter-mismatch`). Without it a message is its code, its number and its values ([Error messages](#error-messages-and-numbers)), and those warnings are not printed |
|
|
232
239
|
| `@wcstack/state/define` | `defineState` and the types only โ no runtime at all |
|
|
233
240
|
|
|
234
|
-
A declaration whose
|
|
235
|
-
`[wcs/feature-not-installed]
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
241
|
+
A declaration whose add-on is missing does not fail quietly: it throws
|
|
242
|
+
`[wcs/feature-not-installed] $watch needs the add-on @wcstack/state/features/temporal` when the state
|
|
243
|
+
is loaded (`mount=`, `bind-component` and `data-wc-definition` name `scopes` the same way, and
|
|
244
|
+
`enable-ssr` names `ssr`), and a formatting filter without `formats` throws `[wcs/filter-unknown]`
|
|
245
|
+
naming the add-on when the bindings are planned. Installing is idempotent โ an add-on installed
|
|
246
|
+
again is skipped โ and every entry shares one core chunk: an add-on never carries a second copy of
|
|
247
|
+
the engine, and never a copy of another add-on either (a call across that seam goes through a
|
|
248
|
+
receptacle the owning add-on fills on install).
|
|
249
|
+
|
|
250
|
+
#### The split auto entry
|
|
251
|
+
|
|
252
|
+
`dist/split/auto.js` loads the split build from one script tag, without an import map: it loads the
|
|
253
|
+
core, and the add-ons the page names from the same build.
|
|
254
|
+
|
|
255
|
+
```html
|
|
256
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0-rc.2/dist/split/auto.js"></script>
|
|
257
|
+
<wcs-state features="scopes diagnostics">
|
|
258
|
+
<script type="module">
|
|
259
|
+
export default {
|
|
260
|
+
$features: ["temporal", "formats"],
|
|
261
|
+
price: 1200,
|
|
262
|
+
$watch: { price(cur) { console.log(cur); } },
|
|
263
|
+
};
|
|
264
|
+
</script>
|
|
265
|
+
</wcs-state>
|
|
266
|
+
<p>{{ price|locale }}</p>
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The add-ons are named in two places, for two different reasons:
|
|
270
|
+
|
|
271
|
+
| | `features="โฆ"` on the root `<wcs-state>` | `$features: [...]` in a state |
|
|
272
|
+
|---|---|---|
|
|
273
|
+
| Where | The document's root `<wcs-state>` โ the first one without `mount` and `bind-component` | Any state that is not a volume (a root, a mounted component, a DCC) |
|
|
274
|
+
| When it is read | Once, before `<wcs-state>` is defined | When that state is loaded, before its engine is built |
|
|
275
|
+
| What it is for | What must be there before any `<wcs-state>` starts โ `scopes`, which decides what a `mount=` / `bind-component` / DCC element is โ and the page's development aids (`diagnostics`, `devtools`) | The add-ons that state needs (`temporal` for its `$watch`, `formats` for its filters) |
|
|
276
|
+
| Who reads it | Only the split auto entry | Every entry: the split auto entry loads the missing add-ons; the other entries check that they are installed and fail the state with `[wcs/feature-not-installed]` otherwise |
|
|
277
|
+
|
|
278
|
+
The names are `formats`, `diagnostics`, `temporal`, `list-keys`, `scopes`, `recursion`, `ssr` and
|
|
279
|
+
`devtools`. Any other name in `features=` makes the split auto entry fail with `[wcs/feature-unknown]`
|
|
280
|
+
before it defines `<wcs-state>`; in `$features` it fails that state (`[wcs/feature-unknown]` on the
|
|
281
|
+
split auto entry, `[wcs/feature-not-installed]` elsewhere). A `$features` that is not an array throws
|
|
282
|
+
`#46`, and a volume may not declare one.
|
|
283
|
+
|
|
284
|
+
Load `dist/split/auto.js` from a plain, version-pinned `/npm/` path, never through `esm.run`, for the
|
|
285
|
+
reason above. It is not in `exports`: a bundler cannot follow the URLs it builds at run time, so a
|
|
286
|
+
bundled page imports `@wcstack/state/core` and calls `installFeatures` instead. The add-ons it imports
|
|
287
|
+
at run time are outside the `integrity` of its `<script>`; under a CSP they need the CDN host allowed.
|
|
242
288
|
|
|
243
289
|
## Basic Usage
|
|
244
290
|
|
|
@@ -332,6 +378,14 @@ a feature never carries a second copy of the engine, and never a copy of another
|
|
|
332
378
|
|
|
333
379
|
Resolution order: `state` โ `src` (.json / .js) โ `json` โ inner `<script>` โ wait for `setInitialState()`.
|
|
334
380
|
|
|
381
|
+
A root `<wcs-state>` taken out of its root while its state loads (the page's content replaced, or the element moved) mounts nothing there and starts again where it is connected next, at once if it already is: a state given by `setInitialState()` is kept for that, but a `state` / `src` / `json` / inline-script source is read again โ a fetch runs again, and an inline module is evaluated again, its top-level code included.
|
|
382
|
+
|
|
383
|
+
`state="<id>"` reads only a `<script type="application/json">` with that id โ in the element's own
|
|
384
|
+
root first (a shadow root), then in the document; another element with the same id is not it. A
|
|
385
|
+
source that cannot be read fails the element's initialization: no such script (`#16`), a `.json`
|
|
386
|
+
that cannot be fetched (`#13`, with the HTTP status), JSON that does not parse, a module that throws
|
|
387
|
+
([Initialization failures](#initialization-failures)). An empty JSON script is `{}`.
|
|
388
|
+
|
|
335
389
|
The browser also evaluates form 5's `<script type="module">` itself โ being inside `<wcs-state>` does not stop it. Its export goes nowhere, so the state is unaffected, but its top-level code runs twice (once by the browser, once by state). Keep side effects (requests, logging, assignments to globals) out of the top level; under a CSP, see the note below.
|
|
336
390
|
|
|
337
391
|
> **Under a Content-Security-Policy:** form 5 (inline `<script type="module">`) is evaluated through a `blob:` URL and therefore requires either the page's nonce on the `<script>` that loads state (the blob: import inherits it) or `script-src blob:`. If you enforce a strict CSP, use form 4 (`src="./state.js"`) instead โ it needs no extra directive. Note that the browser evaluates form 5's `<script>` itself as well: under a CSP that shows up as one violation in the console unless that `<script>` carries the nonce too, and otherwise (with that nonce, or with no CSP) its top-level code runs twice โ keep side effects out of it. See [docs/csp.md](../../docs/csp.md).
|
|
@@ -347,34 +401,30 @@ There is **one state tree per root**. To split state across modules, mount a vol
|
|
|
347
401
|
<div data-wcs="textContent: cart.total"></div>
|
|
348
402
|
```
|
|
349
403
|
|
|
350
|
-
A volume
|
|
404
|
+
A volume holds data, getters, setters and methods, and may declare `$connectedCallback` / `$disconnectedCallback`. Inside all of them `this` is rooted at the mount path: `this.items` is the tree's `cart.items`, and the `$` APIs that take a path (`$getAll`, `$setAll`, `$resolve`, `$postUpdate`, `$dependOn`, `$eq`, `$eqPath`, `$eqIndex`) take it relative to the mount. Its getters and setters become accessors of the tree at `cart.<key>`, and its methods are reachable by path โ `onclick: cart.checkout`, `this["cart.checkout"]()` from the root. Load order does not matter: a volume connected before the root is grafted when the root's engine is created, before the page is bound.
|
|
351
405
|
|
|
352
|
-
|
|
406
|
+
A volume does not run the declarations that react to the whole tree. `$watch`, `$stream`, `$listKeys`, `$renderedCallback`, `$recursion`, `$behavior` and `$features` โ and the removed `$scan`, `$streams` and `$updatedCallback` โ refuse the volume: it is not grafted, and `console.error` names the element and says `$watch is not run in a volume โ declare it on the root state.` Declare them on the root state with full paths โ inside the root's handlers `this` is the root (`$watch: { "cart.total"(cur) { โฆ } }`), and the root's `$renderedCallback(paths)` receives paths written from the root (`cart.items.*.name`), so filter them by the prefix. `$commandTokens`, `$eventTokens`, `$on` and `$errorCallback` are not run, with a `console.warn`. A volume takes no injections either: a `data-wcs` on the volume element (`state.taxRate: settings.taxRate`) refuses it โ read the root path in a root getter (`get cartTotalWithTax() { return this["cart.subtotal"] * (1 + this["settings.taxRate"]); }`).
|
|
353
407
|
|
|
354
|
-
|
|
355
|
-
<wcs-state mount="cart" src="./cart.js" data-wcs="state.taxRate: settings.taxRate"></wcs-state>
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
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`.
|
|
408
|
+
A volume that is refused settles without grafting, reports why, and resolves its `connectedCallbackPromise`; so does one whose state fails to load. Other refusals: a mount path another volume already holds (`will not graft: another volume already holds "cart".`), a path the root state already has (`will not graft: the root state already has "cart".` โ except under SSR, where the volume adopts the server's data there), and a volume in the shadow root of a component wired to its host (`will not graft: its component is wired to its host.` โ that component reads the host's tree; put the data in the host's state). If the root `<wcs-state>` fails to initialize, the volumes waiting for it, and those that load later, report `will not graft: the root state failed to initialize.` and settle instead of waiting forever. That is the end of the line for them: connecting a corrected root afterwards does not bring them back โ remove the broken root and the volumes and add new elements. A throw from a volume's `$connectedCallback` is reported with `console.error`, and the volume stays grafted.
|
|
359
409
|
|
|
360
|
-
|
|
361
|
-
- **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.
|
|
362
|
-
- **`#ro`.** With `state.taxRate#ro: settings.taxRate`, the value can be read but not written. An assignment from the volume's code, `$setAll`, or a writing `$resolve` throws `[wcs/mount-readonly]`; the root itself can still write the path.
|
|
363
|
-
- **Static paths only.** The right side cannot contain a wildcard (`items.*.x`) or start with `$`, because a volume has no loop context. Filters are not accepted either; derive the value in a getter. A malformed injection is reported as `[wcs/mount-path-invalid]` before the volume loads.
|
|
410
|
+
A grafted volume keeps its data and its mount path when it is detached: there is no unmount. A volume still waiting for its root when it is taken out of the page (the page's content replaced) yields its mount path to a new volume that claims it, and settles without grafting. The root may write the paths under it, but a write that would replace an object a volume is grafted under (`this.shop = โฆ` with `mount="shop.cart"`) throws, and so does re-setting the root (`setInitialState()`) or the volume. Mount paths must be static (`*`, `$`, `#`, `@` and whitespace are rejected). Changing `mount` after the element has initialized is not supported, and the change is ignored โ remove the element and add a new one with the desired path.
|
|
364
411
|
|
|
365
|
-
**What each scope runs** (
|
|
412
|
+
**What each scope runs** (nothing here is ignored silently):
|
|
366
413
|
|
|
367
414
|
| Declaration | Root `<wcs-state>` | Volume `<wcs-state mount="p">` | Mounted component (`bind-component` with `state: โฆ`) |
|
|
368
415
|
|---|---|---|---|
|
|
369
|
-
| Data keys, getters, setters, methods | yes | yes, relative to `p` | yes โ own keys are private unless the host maps them; getters are exported |
|
|
416
|
+
| Data keys, getters, setters, methods | yes | yes, relative to `p`; methods reachable by path (`p.method`) | yes โ own keys are private unless the host maps them; getters are exported |
|
|
370
417
|
| `$connectedCallback` / `$disconnectedCallback` | yes | yes, relative to `p` | yes |
|
|
371
|
-
| `$watch`, `$
|
|
372
|
-
| `$
|
|
373
|
-
| `$
|
|
374
|
-
| `$
|
|
375
|
-
|
|
|
418
|
+
| `$watch`, `$stream`, `$renderedCallback` | yes | refused: not grafted, `console.error` | not run โ one `wcs/mount-dollar-declaration` warning |
|
|
419
|
+
| `$listKeys` | yes | refused: not grafted, `console.error` | yes, on the component's own lists |
|
|
420
|
+
| `$recursion`, `**` getters | yes | refused | throws `[wcs/mount-dollar-declaration]` |
|
|
421
|
+
| `$commandTokens`, `$eventTokens`, `$on` | yes | not run โ `console.warn` | yes, on the component's own bindings |
|
|
422
|
+
| `$errorCallback` | yes | not run โ `console.warn` | yes, for the failures of the component's own bindings |
|
|
423
|
+
| `$behavior`, `$features` | yes | refused: not grafted, `console.error` | yes, for the component's own tree |
|
|
424
|
+
| An injection on the element (`data-wcs="state.<key>: โฆ"`) | โ | refused: not grafted, `console.error` | โ (the host's wiring is the mount) |
|
|
425
|
+
| An initialization failure | reported once; `connectedCallbackPromise` rejects | settles without grafting; `connectedCallbackPromise` resolves (a missing `scopes` add-on rejects it) | reported once; the component's `connectedCallbackPromise` rejects |
|
|
376
426
|
|
|
377
|
-
A component that is not mounted (a plain Shadow DOM child with its own `<wcs-state
|
|
427
|
+
A component that is not mounted (a plain Shadow DOM child with its own `<wcs-state>`, or a Shadow DOM `bind-component` the host does not wire) is a root of its own and runs everything in the first column.
|
|
378
428
|
|
|
379
429
|
> **Migrating from v1's named states:** `<wcs-state name="cart">` + `total@cart` becomes `<wcs-state mount="cart">` + `cart.total`. In v2 the `name` attribute fails fast and `@` in a path is a parse error, each with this exact guidance. Migration table: [docs/state-mount-design.md](../../docs/state-mount-design.md) ยง9.
|
|
380
430
|
|
|
@@ -389,6 +439,12 @@ this["user.name"] = "Bob"; // path "user.name"
|
|
|
389
439
|
|
|
390
440
|
That's the one rule: **assign to the path, and the DOM updates automatically.**
|
|
391
441
|
|
|
442
|
+
Writing a top-level key the state does not have creates it (`this.later = 5`). Reading one throws
|
|
443
|
+
`[wcs/binding-path-missing] Path "later" does not exist on the state tree.` (`#301`), so a binding to a
|
|
444
|
+
key the state declares nowhere fails ([Diagnostics](#diagnostics-and-failure-handling)) โ declare
|
|
445
|
+
every key you bind, `null` included, in the initial state. A deeper path that does not exist
|
|
446
|
+
(`user.nickname`) reads as `undefined`.
|
|
447
|
+
|
|
392
448
|
### Why `this.user.name = "Bob"` Doesn't Work
|
|
393
449
|
|
|
394
450
|
This is not just a limitation. It is where the contract boundary becomes visible.
|
|
@@ -424,6 +480,10 @@ this.items.splice(index, 1);
|
|
|
424
480
|
this.items.sort((a, b) => a.id - b.id);
|
|
425
481
|
```
|
|
426
482
|
|
|
483
|
+
Writing one element (`this["items.0"] = obj`) is a path assignment too: it replaces the value at that
|
|
484
|
+
position, and the row at that position shows the new value. To move rows together with their
|
|
485
|
+
values, assign a new array โ see [`$resolve`](#resolve--access-by-explicit-index).
|
|
486
|
+
|
|
427
487
|
## Binding Syntax
|
|
428
488
|
|
|
429
489
|
### `data-wcs` Attribute
|
|
@@ -438,12 +498,12 @@ Multiple bindings separated by `;`:
|
|
|
438
498
|
<div data-wcs="textContent: count; class.over: count|gt(10)"></div>
|
|
439
499
|
```
|
|
440
500
|
|
|
441
|
-
The separators `;` and `|` split only outside quotes
|
|
501
|
+
The separators `;` and `|` split only outside quotes, so a quoted filter argument may contain them: `textContent: tags|join('; ')`, `title: parts|join(' | ')`.
|
|
442
502
|
|
|
443
503
|
| Part | Description | Example |
|
|
444
504
|
|---|---|---|
|
|
445
505
|
| `property` | DOM property to bind | `value`, `textContent`, `checked` |
|
|
446
|
-
| `#modifier` | Binding modifier | `#ro`, `#prevent`, `#stop`, `#onchange` |
|
|
506
|
+
| `#modifier` | Binding modifier | `#ro`, `#prevent`, `#stop`, `#onchange`, `#direct` |
|
|
447
507
|
| `path` | State property path | `count`, `user.name`, `users.*.name` |
|
|
448
508
|
| `\|filter` | Transform filter chain | `\|gt(0)`, `\|round\|locale` |
|
|
449
509
|
|
|
@@ -461,16 +521,16 @@ The separators `;` and `|` split only outside quotes (3.0), so a quoted filter a
|
|
|
461
521
|
| `attr.NAME` | Set an attribute (supports SVG namespace) |
|
|
462
522
|
| `radio` | Radio button group binding (two-way) |
|
|
463
523
|
| `checkbox` | Checkbox group binding to array (two-way) |
|
|
464
|
-
| `onclick`, `on*` | Event handler binding |
|
|
465
|
-
| `.NAME` | Explicit property
|
|
524
|
+
| `onclick`, `on*` | Event handler binding โ see [Event Handling](#event-handling) |
|
|
525
|
+
| `.NAME` | Explicit property: never an event, even when the name starts with `on` |
|
|
466
526
|
|
|
467
|
-
**Properties whose names start with `on
|
|
527
|
+
**Properties whose names start with `on`.** A binding whose name starts with `on` is an event binding (`onclick:`). So `online: x` listens for a `"line"` event and never writes the element's `online` property. To bind such a property, put a dot in front of the name:
|
|
468
528
|
|
|
469
529
|
```html
|
|
470
530
|
<my-status data-wcs=".online: isOnline; onclick: refresh"></my-status>
|
|
471
531
|
```
|
|
472
532
|
|
|
473
|
-
The dotted form is the same binding as the undotted one, except that it is never an event. `.value:` is two-way like `value:`, and modifiers and input filters work as usual. A namespace word (`.class`, `.attr`, `.style`, `.command`, `.eventToken`) or an empty name after the dot is rejected with `[wcs/binding-syntax]`.
|
|
533
|
+
The dotted form is the same binding as the undotted one, except that it is never an event. `.value:` is two-way like `value:`, and modifiers and input filters work as usual. A namespace word (`.class`, `.attr`, `.style`, `.command`, `.eventToken`, `.state`) or an empty name after the dot is rejected with `[wcs/binding-syntax]`.
|
|
474
534
|
|
|
475
535
|
### Modifiers
|
|
476
536
|
|
|
@@ -478,16 +538,17 @@ The dotted form is the same binding as the undotted one, except that it is never
|
|
|
478
538
|
|---|---|
|
|
479
539
|
| `#ro` | Read-only โ disables two-way binding |
|
|
480
540
|
| `#prevent` | Calls `event.preventDefault()` on event handlers |
|
|
481
|
-
| `#stop` | Calls `event.stopPropagation()` on event handlers |
|
|
541
|
+
| `#stop` | Calls `event.stopPropagation()` on event handlers โ on a delegated handler it stops the `on*:` handlers further out, not your own listeners on ancestors (see [Event Handling](#event-handling)) |
|
|
542
|
+
| `#direct` | On an `on*:` binding: attach the listener to the element itself instead of delegating it to the root โ see [Event Handling](#event-handling) |
|
|
482
543
|
| `#onchange` | Uses `change` event instead of `input` for two-way binding |
|
|
483
544
|
| `#init=<authority>` | Binding authority / initial sync direction โ see [Binding Authority](#binding-authority-init--sync) |
|
|
484
545
|
| `#sync=<timing>` | Element snapshot timing โ see [Binding Authority](#binding-authority-init--sync) |
|
|
485
546
|
|
|
486
|
-
Multiple modifiers are comma-separated after a single `#`: `value#ro,init=none: path`.
|
|
547
|
+
Multiple modifiers are comma-separated after a single `#`: `value#ro,init=none: path`, `onclick#direct,stop: save`.
|
|
487
548
|
|
|
488
|
-
A modifier never changes the kind of binding: `radio#ro:` and `checkbox#ro:` stay radio / checkbox bindings
|
|
549
|
+
A modifier never changes the kind of binding: `radio#ro:` and `checkbox#ro:` stay radio / checkbox bindings. A modifier the binding does not use is ignored (`#direct` on a property binding, an unknown `#foo`); an unknown `key=value` modifier (`#foo=1`) throws.
|
|
489
550
|
|
|
490
|
-
|
|
551
|
+
The parser rejects, with `[wcs/binding-syntax]`: a second `#` (write `value#ro,wo`, not `value#ro#wo`), a value after `else:` (write `else:`), modifiers or left-side filters on `for` / `if` / `elseif` / `else` / `...`, output filters on `for:` (`#121` โ see [Loop](#loop-for)), a path segment `__proto__` or `prototype` (`#120` โ refused in writes, `$resolve`, `$setAll` and reads such as `this.__proto__` too), and an unterminated quote in filter arguments.
|
|
491
552
|
|
|
492
553
|
### Two-Way Binding
|
|
493
554
|
|
|
@@ -500,13 +561,13 @@ Automatically enabled for:
|
|
|
500
561
|
| `<select>` | `value` | `change` |
|
|
501
562
|
| `<textarea>` | `value` | `input` |
|
|
502
563
|
|
|
503
|
-
`<input type="button">` is excluded. Use `#ro` to disable, `#onchange` to change the event.
|
|
564
|
+
`<input type="button">` is excluded. Use `#ro` to disable, `#onchange` to change the event. The listener that writes the value back sits on the element itself โ two-way bindings, radio and checkbox are never delegated.
|
|
504
565
|
|
|
505
566
|
### Binding Authority (`#init=` / `#sync=`)
|
|
506
567
|
|
|
507
568
|
**The problem this solves.** An element that already holds a value when its binding attaches โ `<wcs-storage>` after loading a persisted value, a clock, a widget restoring its own snapshot โ is overwritten by the state seed, because the initial sync of a two-way binding writes stateโelement. Adding `#init=element` to that one binding makes the *element* win the initial sync instead; later changes flow both ways as usual. That case (load-before-bind) is spelled out below; the rest of this section is the general rule it is an instance of.
|
|
508
569
|
|
|
509
|
-
For custom elements that declare `static wcBindable`, every prop binding resolves an **authority** โ which side wins the **initial sync** when the binding attaches. The steady-state direction is decided separately, by the member's declared shape: an output-only member never accepts state writes (a permanent contract), while a two-way member flows both ways after the initial sync regardless of which side won it. The default authority is derived from where the member is declared (on by default
|
|
570
|
+
For custom elements that declare `static wcBindable`, every prop binding resolves an **authority** โ which side wins the **initial sync** when the binding attaches. The steady-state direction is decided separately, by the member's declared shape: an output-only member never accepts state writes (a permanent contract), while a two-way member flows both ways after the initial sync regardless of which side won it. The default authority is derived from where the member is declared (on by default; `$behavior: { enableDirectionalInitialSync: false }` turns it off for a state tree โ see [`$behavior`](#behavior)):
|
|
510
571
|
|
|
511
572
|
| Member declared in | Default authority | Effect |
|
|
512
573
|
|---|---|---|
|
|
@@ -521,7 +582,7 @@ For custom elements that declare `static wcBindable`, every prop binding resolve
|
|
|
521
582
|
|
|
522
583
|
When the element dispatches `properties[].event`, the value written to state is **`getter(event)`**. With no `getter`, the protocol default applies โ [`(e) => e.detail`](https://github.com/wc-bindable-protocol/wc-bindable-protocol/blob/main/SPEC.md#default-getter): the **whole `detail`, as-is**. The declared property is *not* read off the element at that point; the event payload is authoritative. A plain HTML element (no `wcBindable`) is the other way round: `element[propName]` is read on `input`/`change`.
|
|
523
584
|
|
|
524
|
-
So an element that dispatches `detail: { value: 7654321 }` without a `getter` writes the **object** `{ value: 7654321 }` to state, not the number โ and the failure is mostly silent: the write-back (`Number({ value: โฆ })` โ `NaN`) throws nothing, and `@wcstack/lint` cannot see it (the payload shape is not static). The
|
|
585
|
+
So an element that dispatches `detail: { value: 7654321 }` without a `getter` writes the **object** `{ value: 7654321 }` to state, not the number โ and the failure is mostly silent: the write-back (`Number({ value: โฆ })` โ `NaN`) throws nothing, and `@wcstack/lint` cannot see it (the payload shape is not static). The `diagnostics` add-on (in the full entries) warns once per element and property (`wcs/default-getter-mismatch`) for the two shapes it can tell apart at the event: a `detail` that is `undefined` while the element property has a value (a plain `Event`, or a forgotten `detail`), and a `detail` object carrying a `<propName>` key while the property is not an object (the wrapper above). Any other mismatch goes through unnoticed, and the write is applied as-is either way. Use one of the two conforming shapes:
|
|
525
586
|
|
|
526
587
|
```javascript
|
|
527
588
|
class YenInput extends HTMLElement {
|
|
@@ -578,9 +639,9 @@ With `sync=connect`, stateโelement writes stay suppressed until the connect sn
|
|
|
578
639
|
|
|
579
640
|
Notes:
|
|
580
641
|
|
|
581
|
-
- With
|
|
582
|
-
-
|
|
583
|
-
-
|
|
642
|
+
- With `$behavior: { enableDirectionalInitialSync: false }` (opt-out), writing `#init=`/`#sync=` throws (`#31`), members are not checked against the declaration, and state wins every initial sync, output-only members included.
|
|
643
|
+
- Do not seed state with placeholder values (`value: []`, `query: ""`) for output-only members โ the element's real initial value (often `null`/`undefined`) replaces the seed. Match the seed to the element's actual initial value and null-guard display values with a derived getter.
|
|
644
|
+
- A binding to a member the declaration does not list throws (`Property "x" is not declared by wcBindable.`, `#38`); so does an `#init=` the member's shape does not allow (`#39`), and `sync=connect` on a member that is not an observable property (`#40`). Authority governs only the initial sync (`docs/architecture-hardening/09-remediation-design.md` ยง3.6).
|
|
584
645
|
|
|
585
646
|
### Radio Binding
|
|
586
647
|
|
|
@@ -618,14 +679,31 @@ A checkbox is checked when its `value` is included in the state array. Toggling
|
|
|
618
679
|
|
|
619
680
|
### Mustache Syntax
|
|
620
681
|
|
|
621
|
-
|
|
682
|
+
`{{ expression }}` in a text node is a text binding โ a path with an optional filter chain, the same as the right side of `textContent:`:
|
|
622
683
|
|
|
623
684
|
```html
|
|
624
685
|
<p>Hello, {{ user.name }}!</p>
|
|
625
686
|
<p>Count: {{ count|locale }}</p>
|
|
626
687
|
```
|
|
627
688
|
|
|
628
|
-
|
|
689
|
+
The text node is split at the braces: the literal text stays, and each `{{ }}` becomes a text node of its own that shows the value. `$behavior: { enableMustache: false }` turns this off for a state tree ([`$behavior`](#behavior)); the braces then stay as text.
|
|
690
|
+
|
|
691
|
+
Before the state loads, a `{{ }}` outside a `<template>` is visible as it is written. Where that flash matters and the page is not server-rendered, write a comment binding instead.
|
|
692
|
+
|
|
693
|
+
#### Comment bindings
|
|
694
|
+
|
|
695
|
+
`<!--@@: expression-->` (or `<!--@@wcs-text: expression-->`) is the same text binding as `{{ expression }}`, filters included, written as a comment โ so nothing shows before it is bound, and no element is added:
|
|
696
|
+
|
|
697
|
+
```html
|
|
698
|
+
<p>Hello, <!--@@: user.name-->!</p>
|
|
699
|
+
<p>Total: <!--@@wcs-text: total|locale--></p>
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
- The comment is replaced by a text node at the same position, at page level and inside `for:` / `if:` templates.
|
|
703
|
+
- It is bound whatever `enableMustache` says: turning `{{ }}` off does not turn comment bindings off.
|
|
704
|
+
- The keyword is empty or `wcs-text`; any other comment (`<!--@@route:โฆ-->`, the anchors this package and `@wcstack/server` write, ordinary comments) is left alone. The expression may span several lines.
|
|
705
|
+
- A comment inside `<textarea>` or `<title>` is not bound: a browser parses that content as text.
|
|
706
|
+
- `@wcstack/lint` and the VS Code extension recommend this form over a `{{ }}` outside a `<template>`.
|
|
629
707
|
|
|
630
708
|
### Spread Binding (`...`)
|
|
631
709
|
|
|
@@ -666,20 +744,34 @@ Runtime reads `customClass.wcBindable.properties + inputs` and expands each name
|
|
|
666
744
|
<wcs-fetch data-wcs="...: usersFetch; status: alternateStatus"></wcs-fetch>
|
|
667
745
|
```
|
|
668
746
|
|
|
669
|
-
**`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
|
|
747
|
+
**`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.) **Display surfaces are different:** `textContent` / `innerText` / `innerHTML`, mustache and comment-binding 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. `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".
|
|
670
748
|
|
|
671
|
-
**Filters keep the contract.** The emptiness test runs on the value *after* the filters, so a filter that
|
|
749
|
+
**Filters keep the contract.** The emptiness test runs on the value *after* the filters, so a filter that built its result with `String(value)` would turn an absent value back into the characters `"undefined"`. The formatting family (`upper`, `lower`, `capitalize`, `trim`, `slice`, `padStart`, `padEnd`, `repeat`, `reverse`, `truncate`, `unit`) passes `undefined` / `null` straight through instead, so `attr.title: x|trim` removes the attribute just as `attr.title: x` does. 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)`).
|
|
672
750
|
|
|
673
751
|
**Constraints**:
|
|
674
752
|
|
|
675
753
|
- Filters on the spread target (`...: target|filter`) are rejected.
|
|
676
754
|
- The right-hand path may contain `*` anywhere (e.g. `...: stores.*.fetch`).
|
|
677
755
|
- The right-hand side is a plain tree path (`...: fetchX` or `...: stores.*.fetch`).
|
|
678
|
-
- If the custom element class is not yet registered, expansion is deferred until `customElements.whenDefined(tag)` resolves โ autoloader-style late registration is supported. This holds inside `for:` / `if:` templates too: the rows and the branch render right away, and each row's element is expanded with that row's values once the class is defined. A row removed, or a branch closed, before the definition is not expanded
|
|
679
|
-
- Elements **without** a `wcBindable` declaration are rejected (write bindings explicitly). Spread requires the contract to know what to expand.
|
|
756
|
+
- If the custom element class is not yet registered, expansion is deferred until `customElements.whenDefined(tag)` resolves โ autoloader-style late registration is supported. This holds inside `for:` / `if:` templates too: the rows and the branch render right away, and each row's element is expanded with that row's values once the class is defined. A row removed, or a branch closed, before the definition is not expanded, and holds nothing alive. The wait is on the registry that defines the element โ a scoped registry of the shadow root it is placed in included.
|
|
757
|
+
- Elements **without** a `wcBindable` declaration are rejected (`[wcs/spread-no-bindable]`; write bindings explicitly) โ a native element at once, without waiting for a definition. Spread requires the contract to know what to expand.
|
|
680
758
|
|
|
681
759
|
**Composite shells** (wc-bindable Composition Profile) are supported transparently: a composite shell exposes its synthesized declaration through the standard `target.constructor.wcBindable` surface, and composed names like `"s3.progress"` are kept as flat element member keys. Mirror the composed structure in state (`{ s3: { progress: 0 } }`) and `...: pipeline` expands into nested state paths automatically.
|
|
682
760
|
|
|
761
|
+
### What the page walk binds
|
|
762
|
+
|
|
763
|
+
When a `<wcs-state>` binds its root (the document, a shadow root, or a Light DOM component's host), it walks the markup once and reads the `data-wcs` attributes, `{{ }}` and comment bindings in it:
|
|
764
|
+
|
|
765
|
+
- **A value is never markup.** What a binding puts into the page โ a `textContent:` or `innerHTML:` value, the children a custom element renders from a bound value โ is never read as bindings: `{{ }}`, `data-wcs` and comment bindings in it stay text.
|
|
766
|
+
- The children of an element whose content a binding sets (`textContent:`, `text:`, `innerText:`, `innerHTML:`, `html:`) are not bound, at page level or inside templates โ also when `#init=element` or `#init=none` leaves the children you wrote in place. Nor are the children of a page-level element bound with `outerHTML:` / `outerText:`. Put markup that needs bindings in a separate element.
|
|
767
|
+
- The content of `<script>`, `<style>`, `<noscript>` and `<iframe>` is not walked; the element's own `data-wcs` (`srcdoc:`, `attr.src:`) still binds.
|
|
768
|
+
- Markup written inside a `<wcs-state>` element is part of the page. A Light DOM component's content is bound by the component's own engine, not by the page.
|
|
769
|
+
- At page level an element's children are bound before the element's own bindings, so a custom element receives its first property writes after its children are bound, and `$errorCallback` receives failures children first. Inside templates the order is document order.
|
|
770
|
+
- `for:` rows and `if:` branches are clones of their templates without the `data-wcs` attribute, so a CSS rule or a test selector on `[data-wcsโฆ]` does not match those elements โ use a class or a `data-*` attribute of your own.
|
|
771
|
+
- Inside a `for:` / `if:` template, a binding that replaces its element (`outerHTML:`, `outerText:`) fails initialization with `[wcs/template-syntax] #203`: a row keeps its nodes by position. Bind `innerHTML:` on a wrapper element instead. (`class.outerHTML:`, `attr.outerText:` are not affected.)
|
|
772
|
+
|
|
773
|
+
Markup that you did not write must not reach this walk โ see [Security](#security).
|
|
774
|
+
|
|
683
775
|
## Structural Directives
|
|
684
776
|
|
|
685
777
|
Structural directives use `<template>` elements:
|
|
@@ -703,6 +795,17 @@ This means **no explicit `key` attribute is needed for adding, removing, or reor
|
|
|
703
795
|
|
|
704
796
|
The exception is data that arrives as **freshly created objects** โ `fetch(...).json()`, `JSON.parse` from storage, a WebSocket/SSE full snapshot, or a Worker `postMessage`. Those rows never match by reference, so every row is torn down and rebuilt. See [`$listKeys`](#listkeys--identity-for-refetched-rows) below.
|
|
705
797
|
|
|
798
|
+
**`for:` takes no output filters.** `for: items|take(2)` fails initialization with `[wcs/binding-syntax] #121`, whatever the filter. A row of `for: items` is `items.<index>`, so the rows of a filtered array would name other elements, and writes through them would land on the wrong ones. Declare a getter that returns the filtered list, and loop over it:
|
|
799
|
+
|
|
800
|
+
```html
|
|
801
|
+
<!-- with get firstTwo() { return this.items.slice(0, 2); } -->
|
|
802
|
+
<template data-wcs="for: firstTwo">
|
|
803
|
+
<li data-wcs="textContent: .name"></li>
|
|
804
|
+
</template>
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
The getter returns a copy, so a write through the original path (`this["items.1.name"] = "z"`) does not reach the copy's row; write through the row instead (`firstTwo.1.name`, or a binding inside the row). The same holds when one object is reachable from two lists (a `get shown()` that filters `todos`, rendered with `for: shown`): see [One object at two positions](#resolve--access-by-explicit-index).
|
|
808
|
+
|
|
706
809
|
#### `$listKeys` โ identity for refetched rows
|
|
707
810
|
|
|
708
811
|
When rows carry DOM state the bindings do not own โ focus, an in-flight IME composition, `<details>` open state, inner scroll position, `<canvas>` contents, `<video>` playback โ rebuilding the rows loses it. Declare a key so the framework can recognize rows across a refresh:
|
|
@@ -732,7 +835,9 @@ Notes:
|
|
|
732
835
|
- **A no-op refresh is free.** If nothing changed, no field is written and no DOM work happens at all.
|
|
733
836
|
- **Rows must be plain objects**, and keys must be present and unique. Duplicate keys, missing keys, and class instances raise an error immediately rather than degrading silently.
|
|
734
837
|
- **Fields dropped from a row are cleared with `null`**, which is this package's vocabulary for an explicit clear (`undefined` means "the state has no opinion" and skips the write).
|
|
838
|
+
- A field whose name is not one path segment (`@odata.etag`, `2fa`, `a.b`), or that has the name of a row getter, is copied into the kept row as it is, and the row is updated as a whole. A `__proto__` key from JSON is not copied.
|
|
735
839
|
- The stored array is rebuilt from matched row objects, so `this.items !== theArrayYouAssigned` afterwards.
|
|
840
|
+
- `$listKeys` is the `list-keys` add-on: the full entries include it; a `/core` page installs `@wcstack/state/features/list-keys`. A mounted component's `$listKeys` keys its own lists; a volume may not declare one.
|
|
736
841
|
|
|
737
842
|
#### Dot Shorthand
|
|
738
843
|
|
|
@@ -783,6 +888,20 @@ Nested loops are supported with multi-level wildcards. The `.` shorthand in nest
|
|
|
783
888
|
|
|
784
889
|
Conditions can be chained. `elseif` automatically inverts the previous condition.
|
|
785
890
|
|
|
891
|
+
### Structural templates in inserted content
|
|
892
|
+
|
|
893
|
+
Content another package inserts after the page was bound โ a route body that `@wcstack/router` shows, a `<wcs-head>` child โ is bound when the inserter hands it over through the binder protocol (`globalThis[Symbol.for("wcstack.binder")].bind(subtree, options?)`). A `for:` / `if:` template at the **top** of such content renders beside itself, where an inserter that moves only its own nodes would leave the rows behind, so it is rendered only when the inserter declares that it carries the whole range in and out โ `bind(subtree, { range: true })`, as `@wcstack/router` 3.5 and later does for route content. Without that declaration it is not rendered, and the console reports `[wcs/template-syntax] #204`. A template wrapped in an element renders in every case:
|
|
894
|
+
|
|
895
|
+
```html
|
|
896
|
+
<wcs-route path="/items">
|
|
897
|
+
<ul>
|
|
898
|
+
<template data-wcs="for: items"><li>{{ .name }}</li></template>
|
|
899
|
+
</ul>
|
|
900
|
+
</wcs-route>
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
The binder never throws for markup reasons: an error in a handed-over subtree is reported with `console.error`, what comes before it stays bound, and the navigation goes on. A subtree handed over again (the router hands its content over on every insertion) is bound once. Only elements are handed over, so a `{{ }}` written directly in a route body, outside any element, is not bound when the route is entered by navigation โ wrap it in an element. This matters only if you call the binder yourself, or write `for:` / `if:` directly under `<wcs-route>`.
|
|
904
|
+
|
|
786
905
|
## Path Getters (Computed Properties)
|
|
787
906
|
|
|
788
907
|
**Path getters** are the core feature of `@wcstack/state`. Define computed properties using JavaScript getters with **dot-path string keys** containing wildcards (`*`). They act as **virtual properties that can be attached at any depth in a data tree โ all defined flat in one place**. No matter how deeply data is nested, path getters keep definitions at the same level with automatic dependency tracking per loop element.
|
|
@@ -1052,13 +1171,15 @@ Two-way binding works with path setters โ editing the input calls the setter,
|
|
|
1052
1171
|
|
|
1053
1172
|
### How It Works
|
|
1054
1173
|
|
|
1055
|
-
1. **Context resolution** โ
|
|
1174
|
+
1. **Context resolution** โ Every element of a list is a row, with its position. Inside a path getter evaluated for a row (a `for:` row, an event handler bound in one, a `$getAll` walk), `this["users.*.name"]` resolves each `*` to that row and its ancestors, so it always points to the current element. A `*` resolves only through the row of *that* list: a row of another list is not a context for it (`wcs/wildcard-rank`).
|
|
1175
|
+
|
|
1176
|
+
2. **Automatic dependency tracking** โ When a getter accesses `this["users.*.name"]`, the system registers a dependency from `users.*.name` to the getter's path. When `users.*.name` changes, the getter's cache is dirtied.
|
|
1056
1177
|
|
|
1057
|
-
|
|
1178
|
+
3. **Caching** โ Getter results are cached per row. `users.*.fullName` at index 0 has a separate cache entry from index 1. The cache is invalidated only when dependencies change.
|
|
1058
1179
|
|
|
1059
|
-
|
|
1180
|
+
4. **Direct index access** โ You can also address a row by its index: `this["users.0.name"]` is the row at index 0 now, without needing loop context. Writing an element (`this["users.0"] = { ...this["users.0"], name: "z" }`) replaces the value at that position: the row stays where it is, takes the new value, and everything derived below it (`users.0.name`, `$getAll("users.*.name")`, row getters) is computed again โ whether or not a `for:` renders the list. Changing the object in place and announcing it with `$postUpdate("users.0")` / `$postUpdate("users")` keeps the row and re-reads what is below it. A binding path with numeric indexes means the same thing โ *whatever row is at index 0 now*: `textContent: users.0.name`, `{{ users.1.name }}`, a row getter such as `{{ users.0.fullName }}`, and paths with several indexes (`groups.0.items.1.v`). It follows writes through an index path, through a `for:` row or an input bound to that row, element replacement, reordering, removals and list replacement. A `$watch` key with a numeric index (`"users.0.name"`) fires only when the value at that index changes. An index past the end of a list reads as `undefined`, and a write there throws `no row for "users.*.name"` (`#3`) and changes nothing.
|
|
1060
1181
|
|
|
1061
|
-
|
|
1182
|
+
Numeric keys under a plain object (`sales.2024.total`, `usersById.42.name`) are a known limitation of this version: markup renders them, but a script read (`this["sales.2024.total"]`, also inside a getter) gives `undefined`, `$eq` on such a path is always false, and a write or a two-way write-back throws `no row for "sales.*.total"`. Read them as `this.sales[2024].total`, write by assigning a new object to the top-level key (`this.sales = { ...this.sales, 2024: { ...this.sales[2024], total: 10 } }`), or use keys that are not numbers.
|
|
1062
1183
|
|
|
1063
1184
|
### Getters must be pure with respect to state
|
|
1064
1185
|
|
|
@@ -1099,7 +1220,7 @@ Three rules decide what the dependency graph sees. None of them matters until yo
|
|
|
1099
1220
|
|---|---|
|
|
1100
1221
|
| **Only path reads through `this` are tracked.** `this.form` tracks `form`; `this["form.name"]` tracks `form.name`; `this.form.name` tracks **`form` only** โ the `.name` is a plain property access on the object that came back. `Date.now()`, the DOM, a module variable, a closed-over object register nothing | The getter is never re-evaluated for that input; the first value sticks (the examples above). A getter that reads `this.form.name` does not re-run when a bound `<input data-wcs="value: form.name">` changes โ read `this["form.name"]` |
|
|
1101
1222
|
| **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 |
|
|
1102
|
-
| **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` (
|
|
1223
|
+
| **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` (`$behavior.sameValueGuard`; a `semantics: "event"` property is exempt either way) |
|
|
1103
1224
|
|
|
1104
1225
|
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.
|
|
1105
1226
|
|
|
@@ -1134,8 +1255,8 @@ What reaches the rows:
|
|
|
1134
1255
|
|
|
1135
1256
|
- **A write to `path`** โ of any value, objects included (`this.selected = row` with `$eq("selected", this["items.*"])`): the row that was selected and the row that becomes selected.
|
|
1136
1257
|
- **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.
|
|
1137
|
-
- **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
|
|
1138
|
-
- **A `path`
|
|
1258
|
+
- **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**; a path that fell back this way holds none, so it is not listed there.
|
|
1259
|
+
- **A `path` inside a list** โ a wildcard (`$eq("items.*.v", โฆ)`) or a numeric index (`$eq("items.0.v", โฆ)`): `$eq` / `$eqPath` key only paths outside lists, so these calls fall back to a tracked read too, and follow a write to that row's `v`, a replacement of the element and of the list (a write to another row's `v` also re-evaluates them, as with any getter).
|
|
1139
1260
|
- **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.
|
|
1140
1261
|
- **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.
|
|
1141
1262
|
|
|
@@ -1183,8 +1304,8 @@ Inside state objects (getters / methods), the following APIs are available via `
|
|
|
1183
1304
|
| `this.$setAll(path, indexes, value, options?)` | Write to every address matching a wildcard path |
|
|
1184
1305
|
| `this.$resolve(path, indexes, value?)` | Resolve a wildcard path with specific indexes |
|
|
1185
1306
|
| `this.$postUpdate(path)` | Manually trigger update notification for a path |
|
|
1186
|
-
| `this.$dependOn(path)`
|
|
1187
|
-
| `this.$untracked(fn)`
|
|
1307
|
+
| `this.$dependOn(path)` | Manually register a dependency for cache invalidation |
|
|
1308
|
+
| `this.$untracked(fn)` | Read values inside fn without registering dependencies |
|
|
1188
1309
|
| `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)) |
|
|
1189
1310
|
| `this.$command.<name>` | Access a `CommandToken` declared in `$commandTokens` (see [Command Token](#command-token-method-binding)) |
|
|
1190
1311
|
| `this.$stateElement` | Access to the `IStateElement` instance |
|
|
@@ -1269,12 +1390,21 @@ One thing `$setAll` is not: a shortcut for the dependency walk. Rendering still
|
|
|
1269
1390
|
|
|
1270
1391
|
#### `$resolve` โ Access by Explicit Index
|
|
1271
1392
|
|
|
1272
|
-
`$resolve` reads or writes a value at a specific wildcard index. **The argument count decides which
|
|
1393
|
+
`$resolve` reads or writes a value at a specific wildcard index. **The argument count decides which**: `$resolve(path, indexes)` reads, and `$resolve(path, indexes, value)` writes `value` โ `undefined` included. On a readonly proxy the write throws `This state is readonly.`, exactly as a direct assignment does, and so does `$setAll`. An index past the end of the list reads `undefined`; a write there throws `no row for "items.*"` and changes nothing.
|
|
1394
|
+
|
|
1395
|
+
**Writing an element replaces the value at that position.** The row at that position stays where it is, with its `$1`, and takes the new value: its bindings show it, and everything derived below the row is computed again. DOM state that bindings do not own โ focus, text typed into an unbound input, `<details>` open state โ stays with the position. An input bound to the row (`value: .`) therefore keeps focus while you type, and an element inside a row that writes its own row (`value: .`, a wc-bindable output bound with `status: .`) keeps landing on that row. To move rows **together with their values**, assign a new array: rows are matched to the elements of the new array by identity, so they move with their objects.
|
|
1273
1396
|
|
|
1274
1397
|
```javascript
|
|
1275
1398
|
export default {
|
|
1276
|
-
items: ["A", "B", "C"],
|
|
1399
|
+
items: [{ name: "A" }, { name: "B" }, { name: "C" }],
|
|
1400
|
+
// โ
the two rows trade places, with their DOM
|
|
1277
1401
|
swapFirstTwo() {
|
|
1402
|
+
const items = this.items.slice();
|
|
1403
|
+
[items[0], items[1]] = [items[1], items[0]];
|
|
1404
|
+
this.items = items;
|
|
1405
|
+
},
|
|
1406
|
+
// the values trade places; the rows stay where they are
|
|
1407
|
+
swapValues() {
|
|
1278
1408
|
const a = this.$resolve("items.*", [0]);
|
|
1279
1409
|
const b = this.$resolve("items.*", [1]);
|
|
1280
1410
|
this.$resolve("items.*", [0], b);
|
|
@@ -1283,13 +1413,7 @@ export default {
|
|
|
1283
1413
|
};
|
|
1284
1414
|
```
|
|
1285
1415
|
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
The swap is complete once every value you moved has left the position it came from. Until then โ between the two writes, while the moved value sits at both positions โ the rows keep their blocks and show the values by position, and a numeric index (`this["items.0"]`) reads and writes that position. The two writes may also run in separate updates (two clicks, say): the update that completes the swap reports to a `$watch` on `items.*` only the position it wrote, with that position's `prev`. Assigning an array before the swap completes โ even an array the path held before โ renders that array's values. A list that holds equal values in separate rows swaps like any other, but a write that copies a value another row keeps holding cannot be told apart from the first half of a swap. So once your code leaves rows holding equal values for good, its index writes into that array are rendered by position until the array is replaced: the values and `$1` are right, but the blocks stop following the values.
|
|
1289
|
-
|
|
1290
|
-
None of this applies to a value that an element inside a row writes to its own row โ an input bound with `value: .`, or a wc-bindable output bound with `status: .`. That write updates the row's value and never counts as part of a swap: the row keeps its block and its `$1`, and the element's later writes keep landing on its own row, even when several rows report the same value or one row's element writes a value another row holds.
|
|
1291
|
-
|
|
1292
|
-
**One object at two positions.** When the same object sits at two positions of a list (`items: [o, o, โฆ]`), a write to a key below one of those rows (`this["items.0.name"] = "z"`) changes the object, but the other row keeps showing and reading the old value: values below a row are cached per row, and a write reaches only the row it was made through. Call `$postUpdate("items")` after such a write, and every row โ the `for:` rows, `$getAll("items.*.name")` and index paths such as `items.1.name` โ reads the object again.
|
|
1416
|
+
**One object at two positions.** When the same object sits at two positions of a list (`items: [o, o, โฆ]`), a write to a key below one of those rows (`this["items.0.name"] = "z"`) changes the object, but the other row's bindings and row getters keep showing the old value: a write reaches only the row it was made through. Plain reads, root getters and `$getAll` see the new value. The same happens when one object is reachable from two lists, as in a TodoMVC-style filter โ `get shown()` returning a filtered copy of `todos`, rendered with `for: shown`, while a checkbox writes the row. Call `$postUpdate("items")` after such a write, and every row of that list shows the object again. This is a known limitation of this version.
|
|
1293
1417
|
|
|
1294
1418
|
## Recursive Paths (`$recursion`)
|
|
1295
1419
|
|
|
@@ -1328,7 +1452,7 @@ export default {
|
|
|
1328
1452
|
|
|
1329
1453
|
For that forest the totals are `131 / 110 / 100 / 20 / 2` and `treeTotal` is `133`.
|
|
1330
1454
|
|
|
1331
|
-
**`**` is authoring notation only โ it never reaches the engine.** Reading a concrete path (`nodes.*.children.*.total`) materializes the getter for *that* depth on demand, one accessor per depth you actually touch, and everything downstream โ
|
|
1455
|
+
**`**` is authoring notation only โ it never reaches the engine.** Reading a concrete path (`nodes.*.children.*.total`) materializes the getter for *that* depth on demand, one accessor per depth you actually touch, and everything downstream โ the path pattern, the dependency graph, `$1`โฆ`$n`, `$resolve`, the list diff โ still sees an ordinary fixed-arity path. The reactive core did not learn a new shape.
|
|
1332
1456
|
|
|
1333
1457
|
### Declaring the recursion point
|
|
1334
1458
|
|
|
@@ -1340,7 +1464,7 @@ $recursion: { "data.tree.*": "kids.*" } // a deeper anchor is fine
|
|
|
1340
1464
|
$recursion: { "nodes.*": "nodes.*" } // self-similar spelling is fine too
|
|
1341
1465
|
```
|
|
1342
1466
|
|
|
1343
|
-
The declaration is what gives `**` a meaning at all: with no `$recursion` on the state, `**` is not a path character (`wcs/recursion-unsupported`), so the notation can never quietly slide into a descendant search. This version accepts **exactly one self-recursive anchor per state**. A wildcard in the middle of an anchor, a second entry, mutual recursion between two anchors, a second `**` in one path, `get "nodes.**"` (that names the node itself, not a computed path under it), a `**` getter whose suffix names the structure (`get "nodes.**.children"()`, `.children.*`, `.children.length` โ it would hide the real child list at every depth), two `**` getters that expand to the same concrete path, and recursive *setters* are all rejected when the declaration is read โ never reinterpreted.
|
|
1467
|
+
The declaration is what gives `**` a meaning at all: with no `$recursion` on the state, `**` is not a path character (`wcs/recursion-unsupported`), so the notation can never quietly slide into a descendant search. This version accepts **exactly one self-recursive anchor per state**. A wildcard in the middle of an anchor, a second entry, mutual recursion between two anchors, a second `**` in one path, `get "nodes.**"` (that names the node itself, not a computed path under it), a `**` getter whose suffix names the structure (`get "nodes.**.children"()`, `.children.*`, `.children.length` โ it would hide the real child list at every depth), two `**` getters that expand to the same concrete path, and recursive *setters* are all rejected when the declaration is read โ never reinterpreted. Three of these checks โ a suffix naming the structure, two getters expanding to one path, and a concrete getter with the same name as an expansion โ are made by the `diagnostics` add-on (in the full entries); a `/core` page without it does not make them.
|
|
1344
1468
|
|
|
1345
1469
|
The family a declaration defines is infinite, and the state only ever grows the depths it is asked for:
|
|
1346
1470
|
|
|
@@ -1364,7 +1488,7 @@ k=2 nodes.*.children.*.children.*
|
|
|
1364
1488
|
| `$setAll(path, [], value)` | Broadcast to every depth, in that same order |
|
|
1365
1489
|
| `$resolve`, `$postUpdate`, `$dependOn`, `$watch` keys, `$listKeys` keys, `data-wcs` in markup, direct assignment | Rejected (`wcs/recursion-unsupported`) |
|
|
1366
1490
|
|
|
1367
|
-
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
|
|
1491
|
+
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 runs for a real row 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.
|
|
1368
1492
|
|
|
1369
1493
|
```javascript
|
|
1370
1494
|
this.$getAll("nodes.**.value", []); // [1, 10, 100, 20, 2] โ depth-first, pre-order
|
|
@@ -1428,11 +1552,13 @@ Every other form is refused *before* the walk writes anything, so a rejected cal
|
|
|
1428
1552
|
| Form | Why it is refused |
|
|
1429
1553
|
|---|---|
|
|
1430
1554
|
| a non-empty prefix | A prefix cannot say which depth it applies to (`wcs/recursion-setall-form`) |
|
|
1431
|
-
| omitted indexes | The write API takes no context, so there is no depth to bind to โ pass `[]` |
|
|
1432
|
-
| a mapper function | `(current, ...indexes)` has a different arity at every depth |
|
|
1433
|
-
| `{ spread: true }` | Handing a flat array to a tree needs the author to know the walk order |
|
|
1555
|
+
| omitted indexes | The write API takes no context, so there is no depth to bind to โ pass `[]` (`#9`, as any `$setAll` without `indexes`) |
|
|
1556
|
+
| a mapper function | `(current, ...indexes)` has a different arity at every depth (`wcs/recursion-setall-form`) |
|
|
1557
|
+
| `{ spread: true }` | Handing a flat array to a tree needs the author to know the walk order (`wcs/recursion-setall-form`) |
|
|
1434
1558
|
| `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`) |
|
|
1435
|
-
| `nodes.**.total`, or a path inside its value โ
|
|
1559
|
+
| `nodes.**.total`, or a path inside its value โ a child node's path folds to the same form, so `nodes.**.children.0.total` and `nodes.**.children.*.total` are the getter too, and a getter declared under a child node (`get "nodes.**.children.*.x"`) refuses `nodes.**.x` as well | A recursive getter has no setter โ write what it derives from (`wcs/recursion-readonly`) |
|
|
1560
|
+
|
|
1561
|
+
Everything else is a leaf, a leaf under a child node included: `$setAll("nodes.**.children.*.value", [], v)` writes the `value` of every node's children โ every node but the roots.
|
|
1436
1562
|
|
|
1437
1563
|
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.
|
|
1438
1564
|
|
|
@@ -1440,9 +1566,9 @@ The read-only rule does not depend on spelling `**`. A recursive getter's concre
|
|
|
1440
1566
|
|
|
1441
1567
|
The walk descends by depth and checks the shape it needs as it goes: reaching the **same array instance** twice is refused. If that array belongs to one of the current node's ancestors it is a cycle (`wcs/recursion-cycle`); otherwise two nodes share one child list (`wcs/recursion-shared-list`). Give every node its own `children` array โ sharing an *empty* one is fine and untracked, because it has no rows to alias.
|
|
1442
1568
|
|
|
1443
|
-
Replacing a row object while keeping its `children` array โ `this.nodes = this.nodes.map(n => ({ ...n }))` โ is an ordinary update, and the aggregates follow it.
|
|
1569
|
+
Replacing a row object while keeping its `children` array โ `this.nodes = this.nodes.map(n => ({ ...n }))` โ is an ordinary update, and the aggregates follow it.
|
|
1444
1570
|
|
|
1445
|
-
|
|
1571
|
+
Outside a recursive walk, rows may share one array (the walk refuses it, as above). Every list over the same array instance shows a write made through any one of them โ a write below `groups.0.items.1` also reaches `groups.2.items.1` when both hold that array, under the same key or another. One object reached through two *different* arrays (a copy of a shared array, a getter returning a filtered copy) is the known limitation described under [One object at two positions](#resolve--access-by-explicit-index).
|
|
1446
1572
|
|
|
1447
1573
|
The ceiling is **128 wildcard levels** on the expanded path. The aggregate above reads one level below the node it is evaluating, so it folds a chain 127 deep and stops at 128 with `wcs/recursion-depth-exceeded`, naming the anchor, the depth reached, the path it was building, and the limit. That check trips before the getter stack's own 128-frame limit (`wcs/getter-depth-exceeded`), so a deep tree is reported as deep instead of being accused of a cycle. Nothing is truncated on the way: a partial aggregate would be a wrong number reported as a right one.
|
|
1448
1574
|
|
|
@@ -1524,11 +1650,36 @@ export default {
|
|
|
1524
1650
|
</template>
|
|
1525
1651
|
```
|
|
1526
1652
|
|
|
1653
|
+
`this` in a handler is the writable state. A dotted name is a path: `onclick: cart.checkout` calls the method of the volume mounted at `cart`. A handler that throws, or returns a Promise that rejects, is reported with `console.error`; the event is not affected.
|
|
1654
|
+
|
|
1655
|
+
### Delegated events
|
|
1656
|
+
|
|
1657
|
+
`on*:` bindings for `click`, `dblclick`, `input`, `change`, `submit`, `keydown`, `keyup`, `mousedown`, `mouseup`, `pointerdown` and `pointerup` are **delegated**: one listener per event type sits on the root the state binds โ the document, a shadow root, or, inside a Light DOM mounted component, the host element โ and it runs the handlers of the elements the event passed, innermost first. So:
|
|
1658
|
+
|
|
1659
|
+
- `event.currentTarget` in the handler is that root, not the element. Find the element from `event.target` (`event.target.closest("li")`), or use the loop index the handler receives (`removeItem(event, index)`).
|
|
1660
|
+
- `#stop` on an inner `on*:` binding stops the `on*:` handlers further out. It cannot stop a listener your own code added to an ancestor (`addEventListener` on a clickable card): that listener ran before the event reached the root.
|
|
1661
|
+
- Your code calling `stopPropagation()` on an ancestor (a modal's content keeping clicks from the overlay) keeps the delegated handlers inside it from running at all.
|
|
1662
|
+
- An element moved under another root (a dialog moved from a shadow root to `document.body`) no longer runs its delegated handlers.
|
|
1663
|
+
- Two handlers of one type on one element (`onclick: a; onclick: b`) both run; a `#stop` on the first does not stop the second, as in the DOM.
|
|
1664
|
+
|
|
1665
|
+
Other event types, and an event a custom element dispatches with `bubbles: false`, are heard on the element itself. Two-way bindings (`value:`, `checked:`, `radio:`, `checkbox:`) and `eventToken.` bindings keep their listener on the element.
|
|
1666
|
+
|
|
1667
|
+
### `#direct`
|
|
1668
|
+
|
|
1669
|
+
`on*#direct:` attaches the listener to the element itself instead of delegating it: `currentTarget` is the element, `#stop` stops your own listeners on ancestors, an ancestor's `stopPropagation()` does not keep it from running, and it still runs after the element moves under another root. It combines with `#prevent` and `#stop`; inside `for:` / `if:` it is attached per row and removed with the row.
|
|
1670
|
+
|
|
1671
|
+
```html
|
|
1672
|
+
<!-- a button inside a card whose click listener your own code added -->
|
|
1673
|
+
<button data-wcs="onclick#direct,stop: save">Save</button>
|
|
1674
|
+
```
|
|
1675
|
+
|
|
1676
|
+
Handlers run where the DOM puts them. An outer `#direct` handler runs **before** the delegated handlers inside it, because those run at the root, after the event has passed the outer element. So an inner `#stop` cannot stop an outer `#direct` handler unless the inner binding is `#direct` too (`onclick#direct,stop:`). `#direct` on a binding that is not an event binding does nothing (`@wcstack/lint` warns about it).
|
|
1677
|
+
|
|
1527
1678
|
## Filters
|
|
1528
1679
|
|
|
1529
|
-
|
|
1680
|
+
47 built-in filters are available for both input (DOM โ state) and output (state โ DOM) directions: 24 logic filters in the core (comparison, arithmetic, type conversion, boolean / default) and 23 formatting filters in the `formats` add-on (number formatting, string, date / time), which the full entries include.
|
|
1530
1681
|
|
|
1531
|
-
|
|
1682
|
+
The names 3.x kept as aliases of the 3.2 renames (`inc`, `dec`, `fix`, `uc`, `lc`, `cap`, `rep`, `rev`, `pad`, `null`) and the `substr` filter do not exist: they fail like any unknown name, and the message names what to write instead (`"uc" was renamed "upper" in 3.2 and removed in 4.0 โ write "upper".`; `substr(start, length)` is `slice(start, start + length)`).
|
|
1532
1683
|
|
|
1533
1684
|
### Comparison
|
|
1534
1685
|
|
|
@@ -1536,7 +1687,7 @@ export default {
|
|
|
1536
1687
|
|---|---|---|
|
|
1537
1688
|
| `eq(value)` | Equal | `count\|eq(0)` โ `true/false` |
|
|
1538
1689
|
| `ne(value)` | Not equal | `count\|ne(0)` |
|
|
1539
|
-
| `not` | Invert truthiness โ `0`, `""`, `null` and `undefined` all give `true`
|
|
1690
|
+
| `not` | Invert truthiness โ `0`, `""`, `null` and `undefined` all give `true` | `isActive\|not` |
|
|
1540
1691
|
| `lt(n)` | Less than | `count\|lt(10)` |
|
|
1541
1692
|
| `le(n)` | Less than or equal | `count\|le(10)` |
|
|
1542
1693
|
| `gt(n)` | Greater than | `count\|gt(0)` |
|
|
@@ -1546,8 +1697,8 @@ export default {
|
|
|
1546
1697
|
|
|
1547
1698
|
| Filter | Description | Example |
|
|
1548
1699
|
|---|---|---|
|
|
1549
|
-
| `add(n)`
|
|
1550
|
-
| `sub(n)`
|
|
1700
|
+
| `add(n)` | Add | `count\|add(1)` |
|
|
1701
|
+
| `sub(n)` | Subtract | `count\|sub(1)` |
|
|
1551
1702
|
| `mul(n)` | Multiply | `price\|mul(1.1)` |
|
|
1552
1703
|
| `div(n)` | Divide | `total\|div(100)` |
|
|
1553
1704
|
| `mod(n)` | Modulo | `index\|mod(2)` |
|
|
@@ -1558,7 +1709,7 @@ export default {
|
|
|
1558
1709
|
|
|
1559
1710
|
| Filter | Description | Example |
|
|
1560
1711
|
|---|---|---|
|
|
1561
|
-
| `toFixed(n)`
|
|
1712
|
+
| `toFixed(n?)` | Fixed decimal places (default 0) | `price\|toFixed(2)` โ `"100.00"` |
|
|
1562
1713
|
| `round(n?)` | Round | `value\|round(2)` |
|
|
1563
1714
|
| `floor(n?)` | Floor | `value\|floor` |
|
|
1564
1715
|
| `ceil(n?)` | Ceiling | `value\|ceil` |
|
|
@@ -1570,16 +1721,15 @@ export default {
|
|
|
1570
1721
|
|
|
1571
1722
|
| Filter | Description | Example |
|
|
1572
1723
|
|---|---|---|
|
|
1573
|
-
| `upper`
|
|
1574
|
-
| `lower`
|
|
1575
|
-
| `capitalize`
|
|
1724
|
+
| `upper` | Upper case | `name\|upper` |
|
|
1725
|
+
| `lower` | Lower case | `name\|lower` |
|
|
1726
|
+
| `capitalize` | Capitalize | `name\|capitalize` |
|
|
1576
1727
|
| `trim` | Trim whitespace | `text\|trim` |
|
|
1577
|
-
| `slice(
|
|
1578
|
-
| `
|
|
1579
|
-
| `
|
|
1580
|
-
| `
|
|
1581
|
-
| `
|
|
1582
|
-
| `reverse` (`rev`) | Reverse | `text\|reverse` |
|
|
1728
|
+
| `slice(start, end?)` | Slice a string (`end` is an index, not a length) | `text\|slice(5)` / `code\|slice(2,5)` |
|
|
1729
|
+
| `padStart(n, char?)` | Pad the start (default `0`) | `id\|padStart(5,0)` โ `"00001"` |
|
|
1730
|
+
| `padEnd(n, char?)` | Pad the end (default a space) | `code\|padEnd(8)` |
|
|
1731
|
+
| `repeat(n)` | Repeat | `text\|repeat(3)` |
|
|
1732
|
+
| `reverse` | Reverse | `text\|reverse` |
|
|
1583
1733
|
| `truncate(n, suffix?)` | Shorten and append an ellipsis (default `โฆ`, one U+2026 character) | `title\|truncate(20)` |
|
|
1584
1734
|
| `join(sep?)` | Join an array (default `", "`) | `tags\|join` / `tags\|join(/)` |
|
|
1585
1735
|
|
|
@@ -1592,7 +1742,7 @@ export default {
|
|
|
1592
1742
|
| `boolean` | To boolean | `value\|boolean` |
|
|
1593
1743
|
| `number` | To number | `value\|number` |
|
|
1594
1744
|
| `string` | To string | `value\|string` |
|
|
1595
|
-
| `nullIfEmpty`
|
|
1745
|
+
| `nullIfEmpty` | Turn an empty string into `null` (anything else passes through) | `value\|nullIfEmpty` |
|
|
1596
1746
|
|
|
1597
1747
|
### Date / Time
|
|
1598
1748
|
|
|
@@ -1604,16 +1754,16 @@ export default {
|
|
|
1604
1754
|
| `ymd(sep?)` | YYYY-MM-DD (default `-`) | `createdAt\|ymd` / `createdAt\|ymd(/)` |
|
|
1605
1755
|
| `hms(sep?)` | HH:MM:SS (default `:`) | `createdAt\|hms` / `createdAt\|hms(-)` |
|
|
1606
1756
|
|
|
1607
|
-
**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
|
|
1757
|
+
**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 the page locale ([Locale](#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).
|
|
1608
1758
|
|
|
1609
1759
|
### Boolean / Default
|
|
1610
1760
|
|
|
1611
1761
|
| Filter | Description | Example |
|
|
1612
1762
|
|---|---|---|
|
|
1613
|
-
| `truthy` | Truthy check โ JavaScript's own truthiness, the same as `boolean` (so `0n` is falsy
|
|
1763
|
+
| `truthy` | Truthy check โ JavaScript's own truthiness, the same as `boolean` (so `0n` is falsy) | `value\|truthy` |
|
|
1614
1764
|
| `falsy` | Falsy check (JavaScript's truthiness; `defaults` uses the same test) | `value\|falsy` |
|
|
1615
1765
|
| `defaults(v)` | Fallback for any falsy value (`0`, `false` and `""` included) | `name\|defaults(Anonymous)` |
|
|
1616
|
-
| `coalesce(v)` | Fallback for `null` / `undefined` only (`0`, `false` and `""` stay
|
|
1766
|
+
| `coalesce(v)` | Fallback for `null` / `undefined` only (`0`, `false` and `""` stay) | `count\|coalesce(0)` |
|
|
1617
1767
|
|
|
1618
1768
|
### Filter Chaining
|
|
1619
1769
|
|
|
@@ -1623,9 +1773,9 @@ Filters can be chained with `|`:
|
|
|
1623
1773
|
<div data-wcs="textContent: price|mul(1.1)|round(2)|locale(ja-JP)"></div>
|
|
1624
1774
|
```
|
|
1625
1775
|
|
|
1626
|
-
A filter is resolved when the bindings are planned. An unknown name throws `[wcs/filter-unknown]` (with a did-you-mean
|
|
1776
|
+
A filter is resolved when the bindings are planned. An unknown name throws `[wcs/filter-unknown]` (`#501`, with a did-you-mean from the `diagnostics` add-on), and 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. Either stops the `<wcs-state>`'s initialization ([Initialization failures](#initialization-failures)). Arguments are cached by their structure, so `join('a,b')` and `join(a)` are never confused.
|
|
1627
1777
|
|
|
1628
|
-
**Argument literals are typed
|
|
1778
|
+
**Argument literals are typed.** 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`, `eq('true')` does not, and `eq(null)` matches `null`. Numbers and strings compare by the value โ 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. 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`.
|
|
1629
1779
|
|
|
1630
1780
|
**Quoting rules.** Quotes (`'` or `"`) mark a literal, and the argument grammar is deliberately small:
|
|
1631
1781
|
|
|
@@ -1643,10 +1793,10 @@ Many frameworks use patterns like prop drilling, context providers, or external
|
|
|
1643
1793
|
|
|
1644
1794
|
1. The child references and updates the parent's state through its own state proxy โ no props, no events, no awareness of the parent.
|
|
1645
1795
|
2. When the parent's state changes, the Proxy `set` trap automatically notifies any child bindings that reference the affected path.
|
|
1646
|
-
3. Because the only coupling is the **path name**, both sides stay loosely coupled. A Shadow DOM component also runs on its own ([standalone injection](#standalone-web-component-injection-
|
|
1647
|
-
4. The cost is path resolution
|
|
1796
|
+
3. Because the only coupling is the **path name**, both sides stay loosely coupled. A Shadow DOM component also runs on its own ([standalone injection](#standalone-web-component-injection-__e2e__single-component)); a Light DOM one does not โ the host has to wire it.
|
|
1797
|
+
4. The cost is path resolution and change propagation through the dependency graph.
|
|
1648
1798
|
|
|
1649
|
-
This is cross-component state management built on path resolution rather than on component-level abstractions.
|
|
1799
|
+
This is cross-component state management built on path resolution rather than on component-level abstractions. What it buys is wiring that stays declarative and inspectable; what it costs is measured under [Performance](#performance).
|
|
1650
1800
|
|
|
1651
1801
|
### Component Definition (Shadow DOM)
|
|
1652
1802
|
|
|
@@ -1689,8 +1839,10 @@ customElements.define("my-light-component", MyLightComponent);
|
|
|
1689
1839
|
- `<wcs-state>` must be a direct child of the component element
|
|
1690
1840
|
- The host **must wire it** (`<my-light-component data-wcs="state.message: user.name">` or `state: user`) โ a plain, unwired Light DOM `bind-component` cannot exist in v2 (an independent tree cannot share the parent's root). It fails loudly with the migration guidance: attach a shadow root, or mount it from the host.
|
|
1691
1841
|
|
|
1692
|
-
> **Note**: `
|
|
1693
|
-
> the component's
|
|
1842
|
+
> **Note**: `getBindingsReady(shadowRoot)` on a Shadow DOM component waits for its `<wcs-state bind-component>`
|
|
1843
|
+
> until the component's bindings are built, and rejects if the mount fails. A Light DOM component binds
|
|
1844
|
+
> content in the page's root, which `getBindingsReady(document)` does not wait for: await the component's
|
|
1845
|
+
> own `<wcs-state>` (`initializePromise`, or `connectedCallbackPromise` to include `$connectedCallback`).
|
|
1694
1846
|
|
|
1695
1847
|
### Host Usage
|
|
1696
1848
|
|
|
@@ -1752,21 +1904,22 @@ customElements.define("user-card", UserCard);
|
|
|
1752
1904
|
- Mounting an array as the root (`state: rows` with `for` over it inside) is not supported; mount the row (`state: .`) or the object that holds the array (`state: group` with `for: children` inside). Both forms are contract-tested; mounts are the only way to extend the tree.
|
|
1753
1905
|
|
|
1754
1906
|
> The per-property form (`state.message: user.name`) keeps working โ it is a partial mount on
|
|
1755
|
-
> the same machinery. **An explicit partial mount wins over the component's own key
|
|
1907
|
+
> the same machinery. **An explicit partial mount wins over the component's own key:** a
|
|
1756
1908
|
> component that declares a default for a mapped key (`state = { message: "" }` together with
|
|
1757
|
-
> `state.message: ...`) reads the host value; the default is simply not used.
|
|
1758
|
-
> that own key private and it hid the host value, with a `wcs/mount-own-key-shadow` warning.)
|
|
1909
|
+
> `state.message: ...`) reads the host value; the default is simply not used.
|
|
1759
1910
|
> R1 still keeps every *unmapped* own key private. This is about a **one-segment** entry: a deeper
|
|
1760
1911
|
> entry (`state.a.b: outer.b`) does not touch the privacy of `a`, so if the component declares `a`
|
|
1761
1912
|
> itself, `a` and everything under it stay private and the entry never reaches it. The same holds
|
|
1762
1913
|
> for an entry whose name is a getter, a setter or a method of the component โ the component's own
|
|
1763
1914
|
> surface wins. Each of those shapes is reported once as `wcs/mount-own-key-shadow`; rename one side.
|
|
1915
|
+
> A mapping whose component-side path contains `*` (`state.list.*: items`) throws โ map the list
|
|
1916
|
+
> itself (`state.list: items`) โ and so does mapping one key twice.
|
|
1764
1917
|
>
|
|
1765
|
-
> **`#ro` on a mount is honoured
|
|
1918
|
+
> **`#ro` on a mount is honoured:** `state#ro: user` or `state.title#ro: doc.title`
|
|
1766
1919
|
> lets the component read the entry but not write it โ `element.state.title = โฆ`, `this.title = โฆ`
|
|
1767
1920
|
> in a method and `$setAll` / `$resolve` writes throw `[wcs/mount-readonly]`, and a two-way
|
|
1768
1921
|
> binding inside the component (`value: title`) does not write back. The host can still write the
|
|
1769
|
-
> path.
|
|
1922
|
+
> path. The mounted `<wcs-state>` needs no `name`
|
|
1770
1923
|
> in Light DOM, and `$getAll` / `$setAll` / `$resolve` / `$postUpdate` on `element.state`
|
|
1771
1924
|
> (and on `this` inside getters/methods) speak the component's own vocabulary โ paths are
|
|
1772
1925
|
> translated onto the mount and the host row's indexes are prepended automatically.
|
|
@@ -1786,7 +1939,7 @@ A mounted component's getters are **exported** at the mount point: **a read of a
|
|
|
1786
1939
|
- Missing-path warnings are deferred by one macrotask (`setTimeout(0)`), independently of `getBindingsReady`. With an autoloader or delayed custom-element definition, an initial warning may appear before the component registers, even when the binding eventually resolves.
|
|
1787
1940
|
- If the tree already has the key (including an inherited property), the tree wins and the runtime warns once (`wcs/mount-export-shadowed`). Two components exporting the same key on the same instance is a configuration error detected during candidate scans (`wcs/mount-export-ambiguous`). A validated cache hit does not rescan other candidates, so adding a conflicting component after the first resolution may escape detection.
|
|
1788
1941
|
- Writing to an exported key from outside runs the accessor's setter, or throws if it only has a getter (the tree never grows a key that would hide the getter). `in` does not see exported keys.
|
|
1789
|
-
- **Self-recursive components** (trees of unbounded depth) become expressible: a component that renders `<template data-wcs="for: children"><tree-node data-wcs="state: ."></tree-node></template>` inside itself can define `get total() { return this.value + this.$getAll("children.*.total").reduce((a, b) => a + (b ?? 0), 0); }` โ each level's formula closes over one level, and the
|
|
1942
|
+
- **Self-recursive components** (trees of unbounded depth) become expressible: a component that renders `<template data-wcs="for: children"><tree-node data-wcs="state: ."></tree-node></template>` inside itself can define `get total() { return this.value + this.$getAll("children.*.total").reduce((a, b) => a + (b ?? 0), 0); }` โ each level's formula closes over one level, and the dependency graph resolves the recursion. Paths cannot express recursion themselves (their wildcard count is fixed), so the recursion lives in the DOM and the paths are its unrolled form. Design: [docs/state-overlay-export-design.md](../../docs/state-overlay-export-design.md).
|
|
1790
1943
|
|
|
1791
1944
|
### Standalone Web Component Injection (`__e2e__/single-component`)
|
|
1792
1945
|
|
|
@@ -1827,7 +1980,11 @@ customElements.define("my-component", MyComponent);
|
|
|
1827
1980
|
- `<wcs-state>` with `bind-component` must be a **direct child** of the component element (top-level)
|
|
1828
1981
|
- The parent element must be a **custom element** (tag name containing a hyphen)
|
|
1829
1982
|
- Light DOM components must be wired from the host (the plain, unwired form was removed in v2)
|
|
1830
|
-
-
|
|
1983
|
+
- The component's state is the host element's property: a `<wcs-state bind-component>` with `state`, `src`, `json` or an inline script is refused, and so is a second connected `<wcs-state bind-component>` in one component (`<tag> already has a connected <wcs-state bind-component="state">.`)
|
|
1984
|
+
- A **mounted** scope runs `$listKeys`, `$commandTokens` / `$eventTokens` / `$on` and `$errorCallback` on its own bindings and lists, and its own `$behavior` / `$features`. It does not run `$watch`, `$stream` and `$renderedCallback` (one `wcs/mount-dollar-declaration` warning), and `$recursion` / `**` getters throw `[wcs/mount-dollar-declaration]`. Declare those on the root state. An unwired Shadow DOM child owns an independent tree and can declare all of them
|
|
1985
|
+
- A host wiring added after the component loaded its state (`state.a: x` bound later) cannot reach it, and throws โ bind the host's wiring before the component loads
|
|
1986
|
+
- A component whose mount fails rejects its `connectedCallbackPromise` โ including a component wired to a root that failed to initialize (`<tag>.state will not mount: the root state failed to initialize.`) โ see [Initialization failures](#initialization-failures)
|
|
1987
|
+
- When a component replaces its `<wcs-state bind-component>` with a new one while nodes the old one bound or rendered are still there, the new element takes the scope over: those bindings, rows and `{{ }}` text stay as they are, and nodes with bindings added beside them are not bound (a `console.warn` says so). When the content is rendered again with it, the new element binds it afresh
|
|
1831
1988
|
|
|
1832
1989
|
### Loop with Components
|
|
1833
1990
|
|
|
@@ -1842,7 +1999,7 @@ customElements.define("my-component", MyComponent);
|
|
|
1842
1999
|
</template>
|
|
1843
2000
|
```
|
|
1844
2001
|
|
|
1845
|
-
A component in a row reads and writes its own row. The `this` of its `$connectedCallback` / `$disconnectedCallback` โ and a `this` kept from them in a timer or across an `await` โ follows the element's row: it keeps writing to that row while an `if:` hides the element
|
|
2002
|
+
A component in a row reads and writes its own row. The `this` of its `$connectedCallback` / `$disconnectedCallback` โ and a `this` kept from them in a timer or across an `await` โ follows the element's row: it keeps writing to that row while an `if:` hides the element. An element write of the host row (`this["users.1"] = obj`) replaces the value at that position: the component element stays, and so do its private keys, while what it reads of the row is the new value. When the row is removed, its component goes with it and renders no more: `$disconnectedCallback` can still read and write the component's own keys (clear the timer id it kept in `this.tid`), but reading or writing the row's keys (`this.name`) throws `The host row of <user-row> was removed.` instead of reaching another row.
|
|
1846
2003
|
|
|
1847
2004
|
### Rendering a List Inside the Component
|
|
1848
2005
|
|
|
@@ -1885,8 +2042,8 @@ Components can also be placed inside components, **stacking scopes**. An interme
|
|
|
1885
2042
|
that only passes the array through โ running no `for:` of its own โ still lets a row-field write
|
|
1886
2043
|
from the owning scope reach the rows at the bottom.
|
|
1887
2044
|
|
|
1888
|
-
A component's author never has to know how deeply it is placed. `$1`, event-handler indexes
|
|
1889
|
-
|
|
2045
|
+
A component's author never has to know how deeply it is placed. `$1`, event-handler indexes
|
|
2046
|
+
and `$getAll` all report positions **within the component's own scope**.
|
|
1890
2047
|
|
|
1891
2048
|
## Command Token (Method Binding)
|
|
1892
2049
|
|
|
@@ -1945,7 +2102,7 @@ export default {
|
|
|
1945
2102
|
- Duplicate entries throw an error at initialization
|
|
1946
2103
|
- The reserved name `$command` itself cannot appear in the array
|
|
1947
2104
|
- Tokens are gathered under `$command` so they do not pollute the top-level state namespace; reactive properties with the same name as a token can coexist
|
|
1948
|
-
- Accessing an undeclared name on `$command` (e.g. `this.$command.typo`) returns `undefined`. The typo then surfaces as a `TypeError` on the subsequent `.emit()` call, or โ when used as a binding right-hand side โ as
|
|
2105
|
+
- Accessing an undeclared name on `$command` (e.g. `this.$command.typo`) returns `undefined`. The typo then surfaces as a `TypeError` on the subsequent `.emit()` call, or โ when used as a binding right-hand side โ as `[wcs/token-undeclared] "$command.typo" is not declared in $commandTokens.` (`#1302`, with a did-you-mean) when the binding is attached
|
|
1949
2106
|
|
|
1950
2107
|
### `command.<methodName>:` Binding
|
|
1951
2108
|
|
|
@@ -1978,13 +2135,15 @@ class MyFetcher extends HTMLElement {
|
|
|
1978
2135
|
}
|
|
1979
2136
|
```
|
|
1980
2137
|
|
|
1981
|
-
>
|
|
2138
|
+
> The `commands` entries must be `{ name, async? }` objects. A plain-string entry (`commands: ["fetch"]`) declares nothing, so a binding to it throws `[wcs/token-misconfigured] <my-fetcher> declares no command "fetch".`
|
|
1982
2139
|
|
|
1983
2140
|
Validation rules (enforced at binding time):
|
|
1984
2141
|
|
|
1985
2142
|
- The element must be a custom element exposing `static wcBindable` with `protocol: "wc-bindable"` and an integer `version` of `1` or later (the current protocol version is `1`; all versions โฅ 1 are core-compatible)
|
|
1986
2143
|
- `methodName` must appear (by `name`) in `wcBindable.commands`
|
|
1987
|
-
-
|
|
2144
|
+
- `<tokenName>` must be declared in `$commandTokens` (`$command.typo` throws `[wcs/token-undeclared]`)
|
|
2145
|
+
|
|
2146
|
+
A custom element is checked once its class is defined; a native element is refused at once (`[wcs/token-misconfigured]`). A violation found while the page is bound fails the `<wcs-state>`'s initialization; one found inside a `for:` / `if:` row, or when a class is defined after the page was bound, fails that one binding, which goes to `$errorCallback`, and the row is still built ([Initialization failures](#initialization-failures)).
|
|
1988
2147
|
|
|
1989
2148
|
### Token API
|
|
1990
2149
|
|
|
@@ -2004,10 +2163,10 @@ interface CommandToken {
|
|
|
2004
2163
|
|
|
2005
2164
|
### Subscription Lifecycle
|
|
2006
2165
|
|
|
2007
|
-
- The subscriber holds the element
|
|
2008
|
-
-
|
|
2009
|
-
-
|
|
2010
|
-
- Disconnecting the owning `<wcs-state>` keeps
|
|
2166
|
+
- The subscriber holds the element only through a `WeakRef`: the token never keeps the element alive. On `emit`, a subscriber whose element was collected unsubscribes itself
|
|
2167
|
+
- An element inside a `for:` row or an `if:` branch unsubscribes when its row or branch goes
|
|
2168
|
+
- An element that is not connected when the token is emitted (a hidden route whose nodes are kept) is not called, and stays subscribed: it receives the next `emit` once it is back in the document
|
|
2169
|
+
- Disconnecting the owning `<wcs-state>` keeps its tokens, so the subscriptions still receive commands after it is re-attached (for example when its host moves in the DOM). A re-set (`setInitialState()`) keeps the token of every name the new state declares again
|
|
2011
2170
|
|
|
2012
2171
|
The element's method is invoked with the arguments from `emit`:
|
|
2013
2172
|
|
|
@@ -2146,7 +2305,7 @@ Validation rules:
|
|
|
2146
2305
|
|
|
2147
2306
|
- The element must be a wc-bindable custom element (`static wcBindable`, `protocol: "wc-bindable"`, integer `version` โฅ 1 โ all versions โฅ 1 are core-compatible). A non-wc-bindable element is rejected at attach time.
|
|
2148
2307
|
- `<property>` must appear in `wcBindable.properties` โ checked at **attach time** (fail-fast; needs only the class, not DOM connection).
|
|
2149
|
-
- `<tokenName>` must be declared in `$eventTokens` โ checked at **fire time
|
|
2308
|
+
- `<tokenName>` must be declared in `$eventTokens` โ checked at **fire time**: the token is looked up when the event fires, so a re-set state's `$on` handlers receive it. An undeclared name is reported with `console.error` (`[wcs/token-undeclared]`, with a did-you-mean).
|
|
2150
2309
|
- Modifiers `#prevent` / `#stop` work as on any event binding: `eventToken.error#prevent: createFailed`.
|
|
2151
2310
|
|
|
2152
2311
|
### Inside a Loop
|
|
@@ -2184,29 +2343,26 @@ $on: {
|
|
|
2184
2343
|
|
|
2185
2344
|
### Token API
|
|
2186
2345
|
|
|
2187
|
-
Event tokens share the same `Token` pub/sub primitive as command tokens โ `name` / `size` / `subscribe` / `unsubscribe` / `emit`, with subscribe-order preservation (see [Token API](#token-api)). The token is resolved from the registry on every event so a re-`setInitialState()` rebuild still reaches the latest `$on` subscribers. Disconnecting the owning `<wcs-state>` keeps
|
|
2346
|
+
Event tokens share the same `Token` pub/sub primitive as command tokens โ `name` / `size` / `subscribe` / `unsubscribe` / `emit`, with subscribe-order preservation (see [Token API](#token-api)). The token is resolved from the registry on every event so a re-`setInitialState()` rebuild still reaches the latest `$on` subscribers. Disconnecting the owning `<wcs-state>` keeps its event tokens and their `$on` handlers.
|
|
2188
2347
|
|
|
2189
2348
|
## Choosing a Time Mechanism
|
|
2190
2349
|
|
|
2191
|
-
The next
|
|
2350
|
+
The next sections answer three different questions, and the usual mistake is to reach for the wrong one. Choose by **what you are declaring**, not by where the data comes from:
|
|
2192
2351
|
|
|
2193
2352
|
| Declaration | What you declare | Owns a value | Fires | Typical use |
|
|
2194
2353
|
|---|---|---|---|---|
|
|
2195
2354
|
| [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 |
|
|
2196
2355
|
| [`$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 |
|
|
2197
|
-
| [`$watch`](#watch-watch) | A reaction to a change | No | Once per batch per changed address
|
|
2198
|
-
| [`$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. Warns in 3.5 and is removed in 4.0: fold in a `$watch` (state paths) or `$on` (event tokens) โ [Preparing for 4.0](#preparing-for-40-wcsv4-migration) |
|
|
2356
|
+
| [`$watch`](#watch-watch) | A reaction to a change | No โ but its handler may write a key of its own | Once per batch per changed address | Side effects, "when this becomes true", accumulations over time |
|
|
2199
2357
|
|
|
2200
2358
|
Two rules cut most of the confusion:
|
|
2201
2359
|
|
|
2202
2360
|
- **`$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).
|
|
2203
|
-
- **A `$stream` fold resets on every restart
|
|
2361
|
+
- **A `$stream` fold resets on every restart.** When an accumulated value has to survive the restart, keep it as an ordinary key and write it from a `$watch` handler on the source path (or from an `$on` handler, to count events rather than states) โ a reset is a `$watch` handler that writes the initial value back. The value is then plain state that you own: a re-set replaces it like any other key.
|
|
2204
2362
|
|
|
2205
2363
|
## Streams (`$stream`)
|
|
2206
2364
|
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
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.
|
|
2365
|
+
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. `$stream` is the `temporal` add-on (in the full entries). The 3.x name `$streams` throws `[wcs/declaration-alias] $streams was removed: write $stream.` (`#1601`).
|
|
2210
2366
|
|
|
2211
2367
|
`$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.
|
|
2212
2368
|
|
|
@@ -2280,8 +2436,8 @@ Key rules:
|
|
|
2280
2436
|
- **Cooperative cancellation (MUST)** โ `source` must observe the passed `AbortSignal` and stop producing when it fires. A `ReadableStream` source satisfies this automatically through its `cancel()` callback (the runtime cancels the reader on abort); only hand-rolled async iterables need to observe `signal` themselves.
|
|
2281
2437
|
- **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.
|
|
2282
2438
|
- **`args` is synchronous** โ returning a Promise is an error, and wildcard reads inside `args` are rejected.
|
|
2283
|
-
- **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
|
|
2284
|
-
- **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
|
|
2439
|
+
- **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 refused at declaration: when the sources yield in the task their run started in, the chain limit cuts the cycle (see [Watch](#watch-watch)); when the values arrive in later tasks (a network response, a message), each lap starts afresh and the cycle restarts forever โ do not build them. One-way chains (A's value feeding B's `args`) are legitimate.
|
|
2440
|
+
- **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. (Without that check the method would be silently overwritten by the start-time reset to `initial`.)
|
|
2285
2441
|
- **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.
|
|
2286
2442
|
|
|
2287
2443
|
See [docs/streams.md](docs/streams.md) for the full contract โ lifecycle and ownership, restart semantics, flush granularity, and the out-of-scope list.
|
|
@@ -2318,7 +2474,7 @@ $renderedCallback(paths) {
|
|
|
2318
2474
|
}
|
|
2319
2475
|
```
|
|
2320
2476
|
|
|
2321
|
-
**The rule:** logic that must not depend on what is rendered belongs on a `$watch
|
|
2477
|
+
**The rule:** logic that must not depend on what is rendered belongs on a `$watch` or a `$stream` `args`. Keep `$renderedCallback` for "follow what was drawn".
|
|
2322
2478
|
|
|
2323
2479
|
That example now folds each landed page into its feed from a `$watch` on the stream's value (and re-arms the sentinel from a `$watch` on the feed), 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`**.
|
|
2324
2480
|
|
|
@@ -2326,11 +2482,11 @@ That example now folds each landed page into its feed from a `$watch` on the str
|
|
|
2326
2482
|
|
|
2327
2483
|
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.
|
|
2328
2484
|
|
|
2329
|
-
Note that a
|
|
2485
|
+
Note that a getter named in `$watch` becomes **eager**: evaluated once at connect, then at the end of every batch touching its dependencies. A wildcard row getter is evaluated for every row of its list, so watching one over a long list costs a walk of that list.
|
|
2330
2486
|
|
|
2331
2487
|
## Watch (`$watch`)
|
|
2332
2488
|
|
|
2333
|
-
`$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
|
|
2489
|
+
`$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, row paths included. It is the `temporal` add-on (in the full entries).
|
|
2334
2490
|
|
|
2335
2491
|
```html
|
|
2336
2492
|
<wcs-state>
|
|
@@ -2339,6 +2495,7 @@ Note that a scalar getter named in `$watch` becomes **eager** (evaluated once at
|
|
|
2339
2495
|
isLoading: false,
|
|
2340
2496
|
items: [],
|
|
2341
2497
|
startedAt: 0,
|
|
2498
|
+
lastPriceChange: "",
|
|
2342
2499
|
|
|
2343
2500
|
$watch: {
|
|
2344
2501
|
// rising-edge detection: you compare cur/prev yourself
|
|
@@ -2347,7 +2504,6 @@ Note that a scalar getter named in `$watch` becomes **eager** (evaluated once at
|
|
|
2347
2504
|
},
|
|
2348
2505
|
|
|
2349
2506
|
// wildcard paths fire once per changed row
|
|
2350
|
-
// (needs the list rendered with `for`, or `$listKeys` declared โ see below)
|
|
2351
2507
|
"items.*.price"(cur, prev, index) {
|
|
2352
2508
|
this.lastPriceChange = `#${index}: ${prev} โ ${cur}`;
|
|
2353
2509
|
},
|
|
@@ -2362,109 +2518,38 @@ The handler runs with `this` bound to a **writable** state proxy, so it can writ
|
|
|
2362
2518
|
| Argument | Contract |
|
|
2363
2519
|
|---|---|
|
|
2364
2520
|
| `cur` | The value at drain time (the settled value for the batch) |
|
|
2365
|
-
| `prev` | The value at the **start of the batch** (first-write-wins). Recorded **only when a primitive is written** (the value before may be an object) โ see below |
|
|
2521
|
+
| `prev` | The value at the **start of the batch** (first-write-wins). Recorded **only when a primitive is written** to the watched path itself (the value before may be an object) โ see below. For a getter: its previous evaluation |
|
|
2366
2522
|
| `...indexes` | Only for wildcard paths: this scope's own loop indexes, same convention as `$1`, `$2` |
|
|
2367
2523
|
|
|
2368
|
-
**`prev` comes only with primitive writes.** It reuses the old value the same-value guard reads before writing a primitive, so watch costs no extra read โ and it is `undefined` when the new value is a reference type (an in-place mutation would give you the same reference anyway), for `$postUpdate`, and when
|
|
2524
|
+
**`prev` comes only with primitive writes.** It reuses the old value the same-value guard reads before writing a primitive, so watch costs no extra read โ and it is `undefined` when the new value is a reference type (an in-place mutation would give you the same reference anyway), when an object above the path was written, for `$postUpdate`, for a row that entered the list, and when `$behavior.sameValueGuard` is off. A primitive written over an object passes that object as `prev`.
|
|
2369
2525
|
|
|
2370
|
-
**Watch adds no firing condition of its own.** It fires for whatever landed in the update batch
|
|
2526
|
+
**Watch adds almost no firing condition of its own.** It fires for whatever landed in the update batch: an equal primitive write is already dropped before it is enqueued (so you effectively get change-only firing), while an occurrence write โ a `semantics: "event"` property โ is deliberately *not* dropped, and still fires with `cur === prev`. Two cases compare values: a key with a numeric index (`"items.0.v"`) fires only when the value at that index changed, and a row getter (`items.*.label`) on a row that a list assignment or re-sort kept fires only when its value changed โ unless a write in the same batch changed something the getter read outside its row (`now`, `items.length`, a root getter that reads them) or wrote the same path in another row. If you need edge detection, compare `cur` and `prev` in the handler.
|
|
2371
2527
|
|
|
2372
|
-
**Watching a getter makes it eager.** A computed getter is normally lazy, and its dependencies are only recorded when it is evaluated โ so an unrendered getter would never fire at all. Declaring one in `$watch` evaluates it once at connect and again at the end of every batch that touches its dependencies. Its `prev` is the previous evaluation. Watch a heavy computed and you pay that evaluation on every batch; exceptions inside it surface through the watch instead of staying dormant.
|
|
2528
|
+
**Watching a getter makes it eager.** A computed getter is normally lazy, and its dependencies are only recorded when it is evaluated โ so an unrendered getter would never fire at all. Declaring one in `$watch` evaluates it once at connect โ for every row, when it is a wildcard getter (`items.*.tax`) โ and again at the end of every batch that touches its dependencies. Its `prev` is the previous evaluation, and a change that reaches it fires the watch whether or not the value changed (but for the two cases above). Watch a heavy computed and you pay that evaluation on every batch; exceptions inside it surface through the watch instead of staying dormant. An evaluation at connect that throws is reported (`$watch initial evaluation of "โฆ" threw.`), that watch's first `prev` is `undefined`, and everything else goes on.
|
|
2373
2529
|
|
|
2374
2530
|
Firing order is defined in three layers, and only the middle one is yours to steer:
|
|
2375
2531
|
|
|
2376
2532
|
| Layer | Order | Your control |
|
|
2377
2533
|
|---|---|---|
|
|
2378
|
-
| Mechanisms | `$renderedCallback` โ `$
|
|
2534
|
+
| Mechanisms | `$renderedCallback` โ `$watch` โ `$stream` restart | fixed |
|
|
2379
2535
|
| Between handlers | declaration order in `$watch` | **reorder the declarations** |
|
|
2380
2536
|
| Between rows of one path | ascending `indexes` | fixed |
|
|
2381
2537
|
|
|
2382
|
-
**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 `$
|
|
2538
|
+
**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 `$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 `$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.
|
|
2383
2539
|
|
|
2384
2540
|
Key rules:
|
|
2385
2541
|
|
|
2386
|
-
- **Paths of the tree only** โ a
|
|
2542
|
+
- **Paths of the tree only** โ a key may not be empty, start with `$`, contain `@` (the v1 name selector), or name an `Object.prototype` member (`toString`); such a declaration throws (`$watch path "โฆ" is not a path of the state tree.`). A key with `**` throws `[wcs/recursion-unsupported]`. A key that does not resolve on the state is warned about by the `diagnostics` add-on (`wcs/watch-path-missing`).
|
|
2387
2543
|
- **Intermediate values are not observable** โ a batch that goes `a โ b โ c` fires once with `cur = c`, `prev = a`, the same contract as binding updates.
|
|
2388
|
-
- **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.
|
|
2389
|
-
- **Row-level diffs want `$listKeys`** โ
|
|
2390
|
-
- **A
|
|
2391
|
-
- **
|
|
2392
|
-
- **
|
|
2393
|
-
- **
|
|
2544
|
+
- **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. Assigning an array fires for the rows that entered the list; a row kept by identity did not change and does not fire.
|
|
2545
|
+
- **Row-level diffs want `$listKeys`** โ data that arrives as fresh objects (a refetch) enters as all-new rows, so the row watch fires for *every* row with `prev === undefined`. With `$listKeys` declared, the key match decomposes the assignment into per-field writes, so only changed rows fire and `prev` is a real scalar.
|
|
2546
|
+
- **A row watch is headless too** โ it needs no `for:` and no `$listKeys`: the watch keeps the lists it ranges over in step itself. Reassigning the same array or a copy of it, and `$postUpdate("items")`, do not fire it โ no row entered the list and no row path was written โ but a getter over the rows (`$getAll("items.*.price", [])`) sees values changed in place.
|
|
2547
|
+
- **Shared arrays fire per position** โ when several outer rows hold the same array, writing one of its elements fires `$watch("groups.*.items.*")` once for every outer row that holds it, each with its own indexes: the value changed at every one of those paths.
|
|
2548
|
+
- **Handler exceptions are isolated** โ a throw is reported (`$watch "โฆ" failed; the other watches still run.`) and the remaining watches (and stream restarts) still run.
|
|
2549
|
+
- **Write chains are bounded** โ a handler's writes form a new batch, so mutually-writing watches would loop forever. Each write carries a chain depth: one more than that of the write that fired the handler making it. A handler fired more than 32 deep does not run: the chain is cut, reported once with `console.error` (`$watch handlers / $stream restarts kept writing for 32 batches; the chain is cut (nothing is rolled back).`) and to DevTools as `state:watch-chain-limit`, while the batch's other handlers still run. Values and DOM are not rolled back. Writes riding in the same batch โ a render's write-back โ neither lengthen nor reset a chain, so a handler that follows a long finite render chain โ `$renderedCallback` shrinking a heading one step per render โ is not cut off. A `$stream` restart counts like a handler: its writes (the reset to `initial`, the status) continue the chain of the write that reached its `args`, and a restart past the limit is not made. A value a source yields in the same task as its run's (re)start counts toward that run's chain, so two streams whose `args` read each other's values with sources that yield at once are cut instead of freezing the page. A value from a later task, and the handlers it fires, start afresh.
|
|
2550
|
+
- **Not run on a mounted `bind-component` scope** โ the `$watch` declaration of a mounted component is ignored with a one-time `wcs/mount-dollar-declaration` warning that points to the root state; this applies to `$stream` and `$renderedCallback` too. A volume refuses `$watch` ([`mount=`](#mounting-additional-state-mount)): declare it on the root with the full path (`"cart.total"`). A plain (unwired Shadow) child owns an independent tree and can declare it.
|
|
2394
2551
|
- **SSR does not run watches** โ handler side effects would otherwise execute on both server and client.
|
|
2395
2552
|
|
|
2396
|
-
## Scan (`$scan`)
|
|
2397
|
-
|
|
2398
|
-
> **`$scan` warns in 3.5 and is removed in 4.0.** Fold a state path from a `$watch` handler that writes the accumulated value, and an event token from an `$on` handler; keep the reset condition in the handler. See [Preparing for 4.0](#preparing-for-40-wcsv4-migration).
|
|
2399
|
-
|
|
2400
|
-
`$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.
|
|
2401
|
-
|
|
2402
|
-
```html
|
|
2403
|
-
<wcs-state>
|
|
2404
|
-
<script type="module">
|
|
2405
|
-
export default {
|
|
2406
|
-
page: 1,
|
|
2407
|
-
host: "a",
|
|
2408
|
-
$eventTokens: ["message"],
|
|
2409
|
-
$stream: {
|
|
2410
|
-
pageResult: { args: (s) => s.page, source: loadPage },
|
|
2411
|
-
},
|
|
2412
|
-
$scan: {
|
|
2413
|
-
// from: fold each landing of a state path โ here, the stream's value
|
|
2414
|
-
feed: {
|
|
2415
|
-
from: "pageResult",
|
|
2416
|
-
initial: { items: [], pages: [] },
|
|
2417
|
-
fold: (feed, chunk) =>
|
|
2418
|
-
chunk?.kind === "success" && !feed.pages.includes(chunk.page)
|
|
2419
|
-
? { items: feed.items.concat(chunk.items), pages: [...feed.pages, chunk.page] }
|
|
2420
|
-
: feed,
|
|
2421
|
-
},
|
|
2422
|
-
// on: fold each event of a declared event token
|
|
2423
|
-
log: {
|
|
2424
|
-
on: "message",
|
|
2425
|
-
initial: [],
|
|
2426
|
-
fold: (log, event) => [...log.slice(-49), event.detail],
|
|
2427
|
-
resetOn: ["host"], // back to [] whenever host changes
|
|
2428
|
-
},
|
|
2429
|
-
},
|
|
2430
|
-
};
|
|
2431
|
-
</script>
|
|
2432
|
-
</wcs-state>
|
|
2433
|
-
|
|
2434
|
-
<template data-wcs="for: feed.items">โฆ</template>
|
|
2435
|
-
```
|
|
2436
|
-
|
|
2437
|
-
| Field | Contract |
|
|
2438
|
-
|---|---|
|
|
2439
|
-
| `from` | A state path. Wildcards are allowed; it may not start with `$`, and may not be a getter or sit under one. Declare exactly one of `from` / `on`. |
|
|
2440
|
-
| `on` | An event-token name declared in `$eventTokens`. |
|
|
2441
|
-
| `initial` | Required. The seed of the accumulator, and what `resetOn` returns to. |
|
|
2442
|
-
| `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. |
|
|
2443
|
-
| `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. |
|
|
2444
|
-
|
|
2445
|
-
**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.
|
|
2446
|
-
|
|
2447
|
-
How the two sources fire:
|
|
2448
|
-
|
|
2449
|
-
| | `from` (a path) | `on` (an event token) |
|
|
2450
|
-
|---|---|---|
|
|
2451
|
-
| Unit | One fold per address that landed in an update batch. Writes made in one job are coalesced. | One fold per event. Two events in one task fold twice. |
|
|
2452
|
-
| When | At the end of the drain, before `$watch`. | Inside the event, before that token's `$on` handlers. |
|
|
2453
|
-
| Output visible | From the next batch. A `$watch` on the output fires then, normally with `prev === undefined` (see below). | Immediately. The `$on` handlers of the same event already see it. |
|
|
2454
|
-
|
|
2455
|
-
Key rules:
|
|
2456
|
-
|
|
2457
|
-
- **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`).
|
|
2458
|
-
- **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.
|
|
2459
|
-
- **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.
|
|
2460
|
-
- **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`.
|
|
2461
|
-
- **Keep folds bounded.** An infinite source must fold into a bounded value (the last N, a count), exactly as with `$stream`.
|
|
2462
|
-
- **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.
|
|
2463
|
-
- **`$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`.
|
|
2464
|
-
- **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.
|
|
2465
|
-
|
|
2466
|
-
Reference: [docs/scan.md](https://github.com/wcstack/wcstack/blob/main/packages/state/docs/scan.md). Design record: [docs/state-scan-design.md](https://github.com/wcstack/wcstack/blob/main/docs/state-scan-design.md).
|
|
2467
|
-
|
|
2468
2553
|
## Inputs and Attribute Mirror
|
|
2469
2554
|
|
|
2470
2555
|
`wcBindable.inputs` declares one-way property inputs (state โ element). When an entry sets `attribute`, the framework writes the value to that HTML attribute every time it writes the property, so `attributeChangedCallback`, CSS attribute selectors, and DevTools all stay in sync with the property value.
|
|
@@ -2508,7 +2593,7 @@ Attribute value encoding:
|
|
|
2508
2593
|
Notes:
|
|
2509
2594
|
|
|
2510
2595
|
- `inputs` entries **without** `attribute` are property-only โ the value is written to the property but no attribute is touched
|
|
2511
|
-
- Mirror is best-effort: a `setAttribute` failure is swallowed
|
|
2596
|
+
- Mirror is best-effort: a `setAttribute` failure is swallowed and does not block the property write
|
|
2512
2597
|
- Native HTML elements ignore `inputs` entirely โ the mirror only activates for custom elements that expose `static wcBindable`
|
|
2513
2598
|
|
|
2514
2599
|
## Choosing a Component Mechanism
|
|
@@ -2590,7 +2675,7 @@ This generates:
|
|
|
2590
2675
|
|
|
2591
2676
|
- `static wcBindable` on the class โ protocol metadata for framework adapters. Each `$bindables` member is declared in both `properties` and `inputs` (two-way), so parent-state โ DCC writes keep working under directional initial sync โ see [Binding Authority](#binding-authority-init--sync). Each `$commands` member becomes a `commands` entry
|
|
2592
2677
|
- Getter/setter on the prototype for `$bindables`, a method for `$commands` โ both go through the reactive proxy
|
|
2593
|
-
- `CustomEvent` dispatch โ `my-counter:count-changed` fires on every mutation of a `$bindables` member
|
|
2678
|
+
- `CustomEvent` dispatch โ `my-counter:count-changed` fires on every mutation of a `$bindables` member (a write of the member, of a path under it, or its `$postUpdate`); `detail` carries the written value only for a write of the member itself, and the declared `getter` reads the member off the element
|
|
2594
2679
|
|
|
2595
2680
|
`commands` entries are always declared `async: true`. A DCC method chains on the inner `<wcs-state>`'s initialization, so it returns a Promise whether or not the state method itself was written `async`.
|
|
2596
2681
|
|
|
@@ -2660,7 +2745,7 @@ Properties prefixed with `$` are internal and not exposed on the component proto
|
|
|
2660
2745
|
| `$commands` | Declares invocable methods |
|
|
2661
2746
|
| `$connectedCallback` | Lifecycle hook (runs on each instance) |
|
|
2662
2747
|
| `$disconnectedCallback` | Cleanup hook |
|
|
2663
|
-
| `$renderedCallback` | Called after
|
|
2748
|
+
| `$renderedCallback` | Called after bindings are applied |
|
|
2664
2749
|
|
|
2665
2750
|
## SVG Support
|
|
2666
2751
|
|
|
@@ -2678,7 +2763,7 @@ All bindings work inside `<svg>` elements. Use `attr.*` for SVG attributes:
|
|
|
2678
2763
|
|
|
2679
2764
|
State objects can define `$connectedCallback`, `$disconnectedCallback`, `$renderedCallback`, and `$errorCallback` for initialization, cleanup, update, and binding-failure handling.
|
|
2680
2765
|
|
|
2681
|
-
>
|
|
2766
|
+
> `$renderedCallback` receives updates of **applied bindings**, not every state update (use `$watch` for those). The 3.x name `$updatedCallback` throws `[wcs/declaration-alias] $updatedCallback was removed: write $renderedCallback.` (`#1601`).
|
|
2682
2767
|
|
|
2683
2768
|
```html
|
|
2684
2769
|
<wcs-state>
|
|
@@ -2705,7 +2790,7 @@ State objects can define `$connectedCallback`, `$disconnectedCallback`, `$render
|
|
|
2705
2790
|
|
|
2706
2791
|
| Hook | Timing | Async |
|
|
2707
2792
|
|---|---|---|
|
|
2708
|
-
| `$connectedCallback` |
|
|
2793
|
+
| `$connectedCallback` | Once the state is loaded and the bindings are built, on first connect; on every reconnect thereafter | Yes (awaited) |
|
|
2709
2794
|
| `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
|
|
2710
2795
|
| `$renderedCallback(paths, indexesListByPath)` | After updates are applied to live bindings | Yes (not awaited) |
|
|
2711
2796
|
| `$errorCallback(error, info)` | After a drain in which a binding failed to apply โ once per failed binding, after `$renderedCallback` | Yes (not awaited) |
|
|
@@ -2715,8 +2800,8 @@ All hooks except `$disconnectedCallback` support `async` โ you can use `async/
|
|
|
2715
2800
|
- `this` inside hooks is the state proxy with full read/write access
|
|
2716
2801
|
- `$connectedCallback` is called **every time** the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
|
|
2717
2802
|
- `$disconnectedCallback` is called synchronously โ use it for cleanup such as clearing timers, removing event listeners, or releasing resources
|
|
2718
|
-
- `$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.
|
|
2719
|
-
- `$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:
|
|
2803
|
+
- `$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. A mounted component's bindings, and so its private keys, never appear in the host's `paths`. Can be `async`, but the return value is not awaited; one that throws or rejects is reported with `console.error`
|
|
2804
|
+
- `$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; `node` is `null` for a list that no `for:` renders โ one only `$getAll` or `$watch` keeps). A binding refused as it is attached inside a `for:` / `if:` row (an undeclared token, a member the element does not declare) is reported here too, as that binding's failure, and the row is still built. `this` is the writable state proxy, so the usual shape is to write the message into state and render it like anything else:
|
|
2720
2805
|
|
|
2721
2806
|
```js
|
|
2722
2807
|
export default {
|
|
@@ -2728,7 +2813,7 @@ All hooks except `$disconnectedCallback` support `async` โ you can use `async/
|
|
|
2728
2813
|
};
|
|
2729
2814
|
```
|
|
2730
2815
|
|
|
2731
|
-
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.
|
|
2816
|
+
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. A volume (`<wcs-state mount>`) does not run it (`console.warn`); a mounted component's receives the failures of the component's own bindings. It does not cover `$watch` handlers (isolated and reported separately), a `$renderedCallback` that throws (reported with `console.error`), or a `$connectedCallback` that fails ([Initialization failures](#initialization-failures)).
|
|
2732
2817
|
- In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
|
|
2733
2818
|
|
|
2734
2819
|
## Transition animations
|
|
@@ -2752,15 +2837,135 @@ li {
|
|
|
2752
2837
|
Two consequences to know while that tag accepts the `state` participant:
|
|
2753
2838
|
|
|
2754
2839
|
- 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.
|
|
2755
|
-
- Because `$
|
|
2840
|
+
- Because `$watch` and the `$stream` restart stay on the original microtask, they run **before** `$renderedCallback` instead of after it.
|
|
2756
2841
|
|
|
2757
2842
|
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.
|
|
2758
2843
|
|
|
2759
2844
|
## Diagnostics and failure handling
|
|
2760
2845
|
|
|
2846
|
+
### Error messages and numbers
|
|
2847
|
+
|
|
2848
|
+
Every message starts with `[@wcstack/state]`. A message about something `@wcstack/lint` and the VS Code extension also check carries the same code (`[wcs/filter-unknown]`), and most messages carry a number (`#501`). The hundreds of a number name its code โ 1xx `binding-syntax`, 2xx `template-syntax`, 3xx `binding-path-missing`, 4xx `binding-type-expectation`, 5xx `filter-unknown`, 6xx `filter-arity`, 7xx `getter-cycle`, 8xx `getter-depth-exceeded`, 9xx `index-arity`, 10xx `index-param-range`, 11xx `recursion-unsupported`, 12xx `token-misconfigured`, 13xx `token-undeclared`, 14xx `wildcard-rank`, 15xx `spread-no-bindable`, 16xx `declaration-alias`, 17xx `name-alias`; 1โ99 carry no code. Numbers are only ever added, so a number keeps its meaning across versions.
|
|
2849
|
+
|
|
2850
|
+
The sentence of a numbered message โ with the nearest name (did-you-mean, by the same rule as the lint), how to fix it, and a pointer to the lint where the lint detects the case โ comes from the `diagnostics` add-on, which `@wcstack/state` and `/auto` include. On a `/core` page without it, a message is its code, its number and the values it would show:
|
|
2851
|
+
|
|
2852
|
+
```
|
|
2853
|
+
[@wcstack/state] [wcs/filter-unknown] filter not found: uc. "uc" was renamed "upper" in 3.2 and removed in 4.0 โ write "upper". Validate statically: npx @wcstack/lint <file>.
|
|
2854
|
+
[@wcstack/state] [wcs/filter-unknown] #501 "uc"
|
|
2855
|
+
```
|
|
2856
|
+
|
|
2857
|
+
The first line is what `@wcstack/state` and `/auto` print, the second what `/core` without `features/diagnostics` prints. Install `diagnostics` while developing on `/core`. The barriers a page without add-ons meets on purpose (`[wcs/feature-not-installed]`, a formatting filter without `formats`) are full sentences in the core.
|
|
2858
|
+
|
|
2859
|
+
<details>
|
|
2860
|
+
<summary>The numbered messages</summary>
|
|
2861
|
+
|
|
2862
|
+
| # | Code | Message (with `diagnostics`) |
|
|
2863
|
+
|---|---|---|
|
|
2864
|
+
| 1 | | `$scan was removed (use $watch or $on)` |
|
|
2865
|
+
| 2 | | `"<path>" is a getter without a setter` |
|
|
2866
|
+
| 3 | | `no row for "<path>"` |
|
|
2867
|
+
| 4 | | `cannot write "<path>": its parent is <value>` |
|
|
2868
|
+
| 5 | | `"<name>" is not a method` |
|
|
2869
|
+
| 6 | | `$getAll("<path>"): no loop level in common with the context` |
|
|
2870
|
+
| 7 | | `$eqIndex("<path>") needs a list row scope.` |
|
|
2871
|
+
| 8 | | `This state is readonly.` |
|
|
2872
|
+
| 9 | | `$setAll("<path>") needs indexes ([] for every match)` |
|
|
2873
|
+
| 10 | | `$setAll("<path>", โฆ, { spread: true }) needs an array of <n> values` |
|
|
2874
|
+
| 11 | | `updates did not settle after 32 passes` |
|
|
2875
|
+
| 12 | | `binding "<type>: <path>" failed to apply.` |
|
|
2876
|
+
| 13 | | `failed to load "<src>": <status>` |
|
|
2877
|
+
| 14 | | `this <wcs-state> failed to initialize; create a new one` |
|
|
2878
|
+
| 15 | | `state is not initialized` |
|
|
2879
|
+
| 16 | | `no <script> with id "<id>"` |
|
|
2880
|
+
| 17 | | `a subscriber of token "<name>" threw.` |
|
|
2881
|
+
| 18 | | `<key> must be an array of strings.` |
|
|
2882
|
+
| 19 | | `<key> entries must be non-empty strings.` |
|
|
2883
|
+
| 20 | | `<key> entry "<name>" conflicts with the reserved namespace name "<reserved>".` |
|
|
2884
|
+
| 21 | | `<key> entry "<name>" is duplicated.` |
|
|
2885
|
+
| 22 | | `$on must be an object of handlers.` |
|
|
2886
|
+
| 23 | | `$on entry "<name>" is not declared in $eventTokens.` |
|
|
2887
|
+
| 24 | | `$on entry "<name>" must be a function.` |
|
|
2888
|
+
| 25 | | `view-transition naming-limit (<limit>) reached.` |
|
|
2889
|
+
| 26 | | `filter <name> requires at least one option` |
|
|
2890
|
+
| 27 | | `filter <name> requires a number as option` |
|
|
2891
|
+
| 28 | | `filter <name> requires a number value` |
|
|
2892
|
+
| 29 | | `filter <name> requires a date value` |
|
|
2893
|
+
| 30 | | `filter <name> requires an array value` |
|
|
2894
|
+
| 31 | | `init=/sync= modifiers require enableDirectionalInitialSync.` |
|
|
2895
|
+
| 32 | | `"<text>": the "@name" selector was removed in v2 โ there is a single state tree. โฆ` |
|
|
2896
|
+
| 33 | | `Unknown binding modifier "<key>" in "<modifier>".` |
|
|
2897
|
+
| 34 | | `Binding modifier "<key>" may only be specified once.` |
|
|
2898
|
+
| 35 | | `Invalid <key> modifier value "<value>".` |
|
|
2899
|
+
| 36 | | `Event bindings only allow init=none.` |
|
|
2900
|
+
| 37 | | `Binding type "<type>" does not support init=<init>.` |
|
|
2901
|
+
| 38 | | `Property "<name>" is not declared by wcBindable.` |
|
|
2902
|
+
| 39 | | `init=<init> is incompatible with wcBindable member "<name>".` |
|
|
2903
|
+
| 40 | | `sync=connect requires observable property "<name>".` |
|
|
2904
|
+
| 41 | | `render chain depth limit exceeded (100 drains that rendering itself started); bindings for this batch were not applied.` |
|
|
2905
|
+
| 42 | | `The inline <script> of <wcs-state> was blocked by Content-Security-Policy. โฆ` |
|
|
2906
|
+
| 43 | | `Failed to evaluate the inline <script> of <wcs-state>: <detail>. โฆ` |
|
|
2907
|
+
| 44 | | `<where>: "<key>" is not one of its options, or not of the option's type.` (`<where>` is `bootstrapState`, `$behavior`, or `state` for a `$behavior` that is not an object) |
|
|
2908
|
+
| 45 | | `a re-set state may not change $behavior: create the element again.` |
|
|
2909
|
+
| 46 | | `$features must be an array of add-on names (["temporal", "formats"]).` |
|
|
2910
|
+
| 47 | | `a second <wcs-state> on the same root: there is one state tree per root โ โฆ` |
|
|
2911
|
+
| 48 | | `the locale "<locale>" (<html lang> or bootstrapState's locale) is not a language tag Intl takes (en-US, not en_US): the locale filters use "en".` |
|
|
2912
|
+
| 49 | | `<wcs-state src="โฆ"> failed to initialize.` |
|
|
2913
|
+
| 50 | | `<wcs-state src="โฆ"> $connectedCallback failed.` |
|
|
2914
|
+
| 101 | `binding-syntax` | `Invalid bindText: "<text>". Missing ':' separator between propPart and statePart.` |
|
|
2915
|
+
| 102 | `binding-syntax` | `"<text>": "<keyword>" takes no modifiers or filters on its left side โ write "<keyword>:".` |
|
|
2916
|
+
| 103 | `binding-syntax` | `"<text>": "else" takes no value โ write "else:".` |
|
|
2917
|
+
| 104 | `binding-syntax` | `Invalid spread binding "<text>": spread target path is required.` |
|
|
2918
|
+
| 105 | `binding-syntax` | `Invalid spread binding "<text>": filters are not allowed on spread targets.` |
|
|
2919
|
+
| 106 | `binding-syntax` | `"<prop>": a leading "." binds an element property by name โ โฆ` |
|
|
2920
|
+
| 107 | `binding-syntax` | `unterminated <quote> quote in the filter arguments "(<args>)". Close the quote.` |
|
|
2921
|
+
| 108 | `binding-syntax` | `Invalid filter format: missing closing parenthesis in "<filter>".` |
|
|
2922
|
+
| 109 | `binding-syntax` | `Invalid filter format: missing opening parenthesis in "<filter>".` |
|
|
2923
|
+
| 110 | `binding-syntax` | `Invalid filter format: ")" comes before "(" in "<filter>".` |
|
|
2924
|
+
| 111 | `binding-syntax` | `"<filter>": unexpected "<text>" after the filter's closing ")" โ separate filters with "\|" โฆ` |
|
|
2925
|
+
| 112 | `binding-syntax` | `an empty filter in "<source>" โ remove the extra "\|" or name the filter.` |
|
|
2926
|
+
| 113 | `binding-syntax` | `"<name>" is not a filter name: a modifier list "#<modifiers>" comes before the input filters, not inside one` |
|
|
2927
|
+
| 114 | `binding-syntax` | `"<name>" is not a filter name: "#" cannot appear in one.` |
|
|
2928
|
+
| 115 | `binding-syntax` | `"<prop>": a binding takes one modifier list after a single "#"` |
|
|
2929
|
+
| 116 | `binding-syntax` | `"<prop>": the left side of a binding must name a property โ โฆ` |
|
|
2930
|
+
| 117 | `binding-syntax` | `"<path>" has <n> path segments โ the limit is 512.` |
|
|
2931
|
+
| 119 | `binding-syntax` | `"<text>": the right side of a binding must name a state path โ โฆ` |
|
|
2932
|
+
| 120 | `binding-syntax` | `"<path>": a state path cannot go through "__proto__" or "prototype" (it would reach every object's prototype).` |
|
|
2933
|
+
| 121 | `binding-syntax` | `"<text>": "for:" takes no filters โ โฆ Declare a getter that returns the filtered list and loop over it ("for: <getter>").` |
|
|
2934
|
+
| 201 | `template-syntax` | `Invalid bindText: "<text>". 'if', 'elseif', 'else', and 'for' bindings must be single binding.` |
|
|
2935
|
+
| 202 | `template-syntax` | `"<type>:" must follow an "if:" template` |
|
|
2936
|
+
| 203 | `template-syntax` | `"<name>:" replaces its element, so it cannot be used inside a "for" / "if" template โฆ: bind innerHTML: on a wrapper element instead.` |
|
|
2937
|
+
| 204 | `template-syntax` | `a "<type>:" template at the top of inserted content was not rendered, โฆ wrap it in an element.` |
|
|
2938
|
+
| 301 | `binding-path-missing` | `Path "<path>" does not exist on the state tree.` |
|
|
2939
|
+
| 401 | `binding-type-expectation` | `class.<name> needs a boolean, got <type>.` |
|
|
2940
|
+
| 501 | `filter-unknown` | `filter not found: <name>.` |
|
|
2941
|
+
| 601 | `filter-arity` | `filter "<name>" requires at least <min> argument(s) (<given> given).` |
|
|
2942
|
+
| 602 | `filter-arity` | `filter "<name>" accepts at most <max> argument(s) (<given> given).` |
|
|
2943
|
+
| 701 | `getter-cycle` | `"<path>" depends on itself` |
|
|
2944
|
+
| 801 | `getter-depth-exceeded` | `"<path>"` |
|
|
2945
|
+
| 901 | `index-arity` | `<api>("<path>") takes <depth> index(es), got <n>.` |
|
|
2946
|
+
| 902 | `index-arity` | `<api>("<path>") takes at most <depth> index(es), got <n>.` |
|
|
2947
|
+
| 1001 | `index-param-range` | `"<key>": list index parameters run from $1 to $128.` |
|
|
2948
|
+
| 1101 | `recursion-unsupported` | `"<path>" uses "**", which is not accepted here.` |
|
|
2949
|
+
| 1201 | `token-misconfigured` | `"<prop>: <path>": the right-hand side must be $command.<name>` |
|
|
2950
|
+
| 1202 | `token-misconfigured` | `<tag> declares no static wcBindable (<binding>).` |
|
|
2951
|
+
| 1203 | `token-misconfigured` | `<tag> declares no command "<method>".` |
|
|
2952
|
+
| 1204 | `token-misconfigured` | `<tag> declares no property "<prop>".` |
|
|
2953
|
+
| 1301 | `token-undeclared` | `eventToken "<name>" is not declared in $eventTokens.` |
|
|
2954
|
+
| 1302 | `token-undeclared` | `"$command.<name>" is not declared in $commandTokens.` |
|
|
2955
|
+
| 1401 | `wildcard-rank` | `"<path>" needs <depth> enclosing loop level(s); the scope provides <n>.` |
|
|
2956
|
+
| 1402 | `wildcard-rank` | `"<path>" is relative: it needs an enclosing "for" template` |
|
|
2957
|
+
| 1403 | `wildcard-rank` | `"<path>" ranges over the rows of "<list>", but the enclosing "for" template at that level renders "<loop>".` |
|
|
2958
|
+
| 1501 | `spread-no-bindable` | `<tag> declares no static wcBindable (<binding>).` |
|
|
2959
|
+
| 1601 | `declaration-alias` | `<old> was removed: write <name>.` (`$streams`, `$updatedCallback`) |
|
|
2960
|
+
| 1701 | `name-alias` | `<old> was removed: write <name>.` (`$trackDependency`, `$untrackDependency`) |
|
|
2961
|
+
|
|
2962
|
+
The source of the numbers and the sentences is `src/diagnostics/messages.ts`.
|
|
2963
|
+
|
|
2964
|
+
</details>
|
|
2965
|
+
|
|
2761
2966
|
### Wiring to a path that does not exist is reported
|
|
2762
2967
|
|
|
2763
|
-
When a wired path provably does not resolve against the state,
|
|
2968
|
+
When a wired path provably does not resolve against the state, the `diagnostics` add-on prints one warning, one macrotask after the binding (or the `$watch` key) is set up. The diagnostic codes are shared by the console, `@wcstack/lint`, and the VS Code extension:
|
|
2764
2969
|
|
|
2765
2970
|
```
|
|
2766
2971
|
[@wcstack/state] [wcs/binding-path-missing] Bound path "user.nmae" does not resolve on the state tree:
|
|
@@ -2771,47 +2976,46 @@ dropped. Validate statically: npx @wcstack/lint <file>.
|
|
|
2771
2976
|
| Situation | Behavior |
|
|
2772
2977
|
|---|---|
|
|
2773
2978
|
| Typo in a nested path (`user.nmae`) | `console.warn` (`wcs/binding-path-missing`). Updates still never arrive โ you fix it |
|
|
2774
|
-
| Typo in a top-level path (`cout`) | Throws on read
|
|
2979
|
+
| Typo in a top-level path (`cout`) | Throws on read โ `[wcs/binding-path-missing] Path "cout" does not exist on the state tree. Did you mean "count"?` (`#301`) โ so the binding fails to apply, reported like any binding failure |
|
|
2775
2980
|
| Typo in a `$watch` key | `console.warn` (`wcs/watch-path-missing`), reported even for a single segment |
|
|
2776
|
-
| Typo in a `$scan` `from` / `resetOn` path | `console.warn` (`wcs/scan-path-missing`), reported even for a single segment. The scan never folds (or never resets) |
|
|
2777
2981
|
|
|
2778
2982
|
The check **under-approximates**: it stays silent for anything it cannot decide statically, because a false alarm costs more than a missed one. None of these warn:
|
|
2779
2983
|
|
|
2780
2984
|
- A `null` / `undefined` parent (the "seed as `null`, assign later" shape)
|
|
2781
2985
|
- Row fields of a list that starts empty (the row shape is unknown)
|
|
2782
2986
|
- Sub-properties of an intermediate getter's return value
|
|
2783
|
-
-
|
|
2987
|
+
- Paths under the mount path of a volume that is still loading โ they are checked once it settles
|
|
2784
2988
|
- Reserved `$` namespaces (`$command.*` and friends)
|
|
2785
2989
|
|
|
2786
|
-
So **no warning is not a proof of correctness.** For exhaustive checking, run `npx @wcstack/lint <file>`.
|
|
2990
|
+
So **no warning is not a proof of correctness.** For exhaustive checking, run `npx @wcstack/lint <file>`. A re-set (`setInitialState()`) checks every path again against the new state.
|
|
2787
2991
|
|
|
2788
2992
|
### Index arity, wildcard rank, and getter cycles are checked too
|
|
2789
2993
|
|
|
2790
|
-
Anything that follows mechanically from the path string is reported at runtime and by the linter under the same diagnostic code.
|
|
2994
|
+
Anything that follows mechanically from the path string is reported at runtime and by the linter under the same diagnostic code. Five of the codes below are **runtime-only** โ the linter does not emit them: `wcs/getter-depth-exceeded`, `wcs/recursion-context`, `wcs/recursion-shared-list`, `wcs/recursion-cycle` and `wcs/recursion-depth-exceeded`.
|
|
2791
2995
|
|
|
2792
2996
|
| Diagnostic | What it checks | Fix |
|
|
2793
2997
|
|---|---|---|
|
|
2794
2998
|
| `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 |
|
|
2795
|
-
| `wcs/wildcard-rank` |
|
|
2796
|
-
| `wcs/getter-cycle` | Path getters must not form a dependency cycle
|
|
2797
|
-
| `wcs/getter-depth-exceeded` | Getter evaluation nests deeper than the engine evaluates in one pass (128 frames), with no
|
|
2999
|
+
| `wcs/wildcard-rank` | In markup, the path's `*` count (and the N in `$N`) must not exceed the enclosing `for` nesting (`#1401`), a `.` path needs an enclosing `for` (`#1402`), and each `*` is the row of the `for` around it at that level โ not a row of another list (`{{ b.*.y }}` inside `for: a`, `#1403`) | Add a `for`, or read the other list's row in a getter with `$resolve(path, indexes)` |
|
|
3000
|
+
| `wcs/getter-cycle` | Path getters must not form a dependency cycle: the evaluation reached a getter occurrence it is already evaluating | Break the cycle |
|
|
3001
|
+
| `wcs/getter-depth-exceeded` | Getter evaluation nests deeper than the engine evaluates in one pass (128 frames), with no occurrence visited twice โ the data is simply that deep | Aggregate in fewer levels, or flatten the tree |
|
|
2798
3002
|
| `wcs/index-param-range` | `$N` must name an existing wildcard level: `$1` through `$128`, no leading zeros | Use a level that exists |
|
|
2799
3003
|
| `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 |
|
|
2800
|
-
| `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, `
|
|
2801
|
-
| `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 |
|
|
3004
|
+
| `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 โ and, with the `diagnostics` add-on, 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 |
|
|
3005
|
+
| `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 โ none at all (`get "nodes.**"`), an empty segment (`nodes.**.`, `nodes.**..x`) or a bare `*` right after `**` (`nodes.**.*`) | Spell the anchor as declared, and a real path after it |
|
|
2802
3006
|
| `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 |
|
|
2803
|
-
| `wcs/recursion-getall-form` / `wcs/recursion-setall-form` | An `indexes` argument `**` cannot define. The code is carried by the non-empty prefix (both APIs) and by a non-array `indexes` on `$getAll` (`null`, a stringโฆ); omission, a mapper and `{ spread: true }` in a `$setAll` are the same mistake and the linter reports them under the same code
|
|
2804
|
-
| `wcs/recursion-structural-write` | A recursive `$setAll` targets the structure itself โ a node, its child list, that list's `length`, a child node, or an object on the way to the child list when the repeating sub-path has several segments | Broadcast to a leaf property instead |
|
|
3007
|
+
| `wcs/recursion-getall-form` / `wcs/recursion-setall-form` | An `indexes` argument `**` cannot define. The code is carried by the non-empty prefix (both APIs) and by a non-array `indexes` on `$getAll` (`null`, a stringโฆ); omission, a mapper and `{ spread: true }` in a `$setAll` are the same mistake and the linter reports them under the same code; at runtime a mapper and `{ spread: true }` throw `[wcs/recursion-setall-form]`, and omitted indexes throw `#9`, as any `$setAll` without `indexes` does | Omit for the current depth, `[]` for every depth |
|
|
3008
|
+
| `wcs/recursion-structural-write` | A recursive `$setAll` targets the structure itself โ a node, its child list, that list's `length`, a child node, or an object on the way to the child list when the repeating sub-path has several segments | Broadcast to a leaf property instead (a leaf under a child node, `nodes.**.children.*.value`, is one) |
|
|
2805
3009
|
| `wcs/recursion-readonly` | A write targets a recursive getter, or a path inside the value it derives โ a recursive `$setAll` on `nodes.**.total`, or any write to a concrete expansion such as `nodes.*.children.*.total` (fixed-arity `$setAll`, `$resolve` with a value, direct assignment) | Write what the getter derives from |
|
|
2806
3010
|
| `wcs/recursion-shared-list` / `wcs/recursion-cycle` | The walk reached the same array instance twice: two nodes sharing one child list, or a list reachable from its own ancestor | Give every node its own child array |
|
|
2807
3011
|
| `wcs/recursion-depth-exceeded` | The expanded path needs more than 128 wildcard levels โ the tree nests deeper than the engine can address, or it contains a cycle | Flatten the tree, or find the cycle |
|
|
2808
3012
|
|
|
2809
3013
|
The form each `wcs/recursion-*` row is refusing โ and the form to write instead โ is spelled out under [Recursive Paths](#recursive-paths-recursion).
|
|
2810
3014
|
|
|
2811
|
-
|
|
3015
|
+
Extra indexes are never discarded: a mixed-up call that would return a plausible-looking wrong value throws instead.
|
|
2812
3016
|
|
|
2813
3017
|
```javascript
|
|
2814
|
-
// โ Only one "*" in the path, two indexes given โ
|
|
3018
|
+
// โ Only one "*" in the path, two indexes given โ throws [wcs/index-arity]
|
|
2815
3019
|
this.$resolve("items.*.price", [row, col]);
|
|
2816
3020
|
|
|
2817
3021
|
// โ
Two levels, two indexes
|
|
@@ -2820,29 +3024,63 @@ this.$resolve("matrix.*.*", [row, col]);
|
|
|
2820
3024
|
this.$getAll("matrix.*.*", [row]);
|
|
2821
3025
|
```
|
|
2822
3026
|
|
|
3027
|
+
### Initialization failures
|
|
3028
|
+
|
|
3029
|
+
A `<wcs-state>` that fails to initialize is reported once with `console.error` โ first the element and where its state comes from, then the error:
|
|
3030
|
+
|
|
3031
|
+
```
|
|
3032
|
+
[@wcstack/state] <wcs-state src="./state.js"> failed to initialize. SyntaxError: โฆ
|
|
3033
|
+
```
|
|
3034
|
+
|
|
3035
|
+
The header (`#49`) names `mount=`, `bind-component=`, `state=` and `src=` when the element has them. Its `connectedCallbackPromise` rejects with the error, unwrapped, and its `initializePromise` resolves, so one element's failure never blocks the rest of the page.
|
|
3036
|
+
|
|
3037
|
+
What fails initialization:
|
|
3038
|
+
|
|
3039
|
+
- A source that cannot be read ([State Initialization](#state-initialization)).
|
|
3040
|
+
- An invalid declaration: a removed name (`$scan`, `$streams`, `$updatedCallback`), a `$behavior` / `$features` / `$commandTokens` / `$eventTokens` / `$on` of the wrong shape, a declaration whose add-on is not installed (`[wcs/feature-not-installed]`).
|
|
3041
|
+
- A markup error found while the page is bound: a syntax error, an unknown filter or a wrong argument count, a wildcard-rank error, `for:` with filters, `outerHTML:` in a template, a wc-bindable misconfiguration on an element whose class is already defined. The walk stops at the error: what comes before it in document order is bound, what comes after it is not. Run `npx @wcstack/lint` before you deploy.
|
|
3042
|
+
- A second root `<wcs-state>` on a root that another one already binds (`#47`). There is one state tree per root; graft a subtree with `mount=`.
|
|
3043
|
+
|
|
3044
|
+
Inside a `for:` / `if:` row, an error found while a binding is attached (an undeclared token, a member the element does not declare) is that binding's failure instead: it goes to `$errorCallback`, and the row is still built. A binding to a top-level key the state does not have is a failed apply, not an initialization failure. An element that failed to initialize cannot be re-armed โ `setInitialState()` throws (`#14`); remove it and create a new one.
|
|
3045
|
+
|
|
3046
|
+
**A failing `$connectedCallback` is not an initialization failure.** `$connectedCallback` runs once the bindings are built; one that throws or rejects is reported as `<wcs-state โฆ> $connectedCallback failed.` (`#50`) followed by the error, and `connectedCallbackPromise` rejects with it โ but the page is bound: `getBindingsReady()` resolves, and the element can still be re-set. On a reconnect, a failing `$connectedCallback` is reported with `console.error`.
|
|
3047
|
+
|
|
3048
|
+
**What the promises promise:**
|
|
3049
|
+
|
|
3050
|
+
| | `initializePromise` | `connectedCallbackPromise` |
|
|
3051
|
+
|---|---|---|
|
|
3052
|
+
| Root `<wcs-state>` | Resolves once the state is loaded and the bindings are built โ and also when initialization fails | Resolves after `$connectedCallback`; rejects on an initialization failure and on a failing `$connectedCallback` |
|
|
3053
|
+
| DCC definition (`<wcs-state>` in a `data-wc-definition` shadow root) | Resolves | Resolves once the tag is defined; rejects when the definition fails (the state fails to load, a `$bindables` / `$commands` error, a `bind-component` there) โ the tag is then not defined |
|
|
3054
|
+
| Mounted component (`<wcs-state bind-component>`) | Resolves once the component's bindings are built โ also on failure | Rejects when the mount fails: the host's property is not an object, a wiring error, a second connected `<wcs-state bind-component>` in one component, a component wired to a root that failed to initialize (`<tag>.state will not mount: the root state failed to initialize.`), or a failing `$connectedCallback` after the component is rendered |
|
|
3055
|
+
| Volume (`<wcs-state mount>`) | Resolves | Always resolves โ a volume that fails or is refused reports and settles without grafting (a missing `scopes` add-on rejects it) |
|
|
3056
|
+
|
|
3057
|
+
`getBindingsReady(root)` resolves once the bindings of `root` (a `document` or a shadow root) are built by a `<wcs-state>` connected to it โ without waiting for `$connectedCallback`, however slow it is, whether it settles or throws. It rejects only when every `<wcs-state>` connected to that root failed before building its bindings, with the first failure. A stray element (a second root refused with `#47`, one whose source fails to load) changes nothing while another binds the root; when the root `<wcs-state>` is replaced (removed, and a new one added), the new one decides. It stays pending while the only `<wcs-state>` there waits for `setInitialState()`, and resolves at once when there is none โ never one, or only ones taken out of that root since. On the shadow root of a DCC definition or of a Shadow DOM component, it follows that `<wcs-state>`: it resolves when the component's bindings are built and rejects when the mount fails. A Light DOM component binds content in the page's root, and its failure does not reach `getBindingsReady(document)`. A volume never makes it reject.
|
|
3058
|
+
|
|
3059
|
+
`@wcstack/server`'s `renderToString()` and `@wcstack/testing`'s `mount()` wait for every `connectedCallbackPromise`, a Light DOM component's included, so they reject in the cases above too. The cases are pinned by [`__tests__/init-failure.test.ts`](__tests__/init-failure.test.ts).
|
|
3060
|
+
|
|
2823
3061
|
### One failing binding is confined to that binding
|
|
2824
3062
|
|
|
2825
|
-
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
|
|
3063
|
+
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 `$errorCallback`, or else to `console.error`, followed by the error, and to DevTools (`state:binding-apply-error`):
|
|
2826
3064
|
|
|
2827
3065
|
```
|
|
2828
|
-
[@wcstack/state] binding "text: items.*.label" failed to apply
|
|
3066
|
+
[@wcstack/state] binding "text: items.*.label" failed to apply.
|
|
2829
3067
|
```
|
|
2830
3068
|
|
|
2831
|
-
|
|
3069
|
+
A list that fails to sync or render (a missing key, a getter that throws) is reported the same way, as its `for` binding's failure, and keeps the rows it had.
|
|
2832
3070
|
|
|
2833
3071
|
### A write made while rendering cannot loop forever
|
|
2834
3072
|
|
|
2835
|
-
Some writes
|
|
3073
|
+
Some writes are fed back by rendering itself: an element's write-back (a two-way input, a wc-bindable event โ including one an element dispatches synchronously while a binding sets one of its properties โ, an output-only member's initial value), and a write made in `$renderedCallback` (also after an `await` in it, until it settles), in a `$watch` handler, or by a `$stream` restart. Each such write starts a new batch, and that batch renders again. If the rendering writes something new every time โ say a row element whose output is bound to the very key the list's getter reads, returning a different value per instance โ the page would spin in microtasks and freeze. Batches in a row that only rendering fed are counted: past 100, the next one is not rendered (its values stay in the state), and one report names the paths it carried:
|
|
2836
3074
|
|
|
2837
3075
|
```
|
|
2838
|
-
[@wcstack/state] render chain depth limit exceeded; bindings for this batch were not applied.
|
|
3076
|
+
[@wcstack/state] render chain depth limit exceeded (100 drains that rendering itself started); bindings for this batch were not applied. ["mode", "view", "view.*", "view.*.m"]
|
|
2839
3077
|
```
|
|
2840
3078
|
|
|
2841
3079
|
The limit is higher than the other two limits (32) because each link here is a whole render. A chain that renders, measures and adjusts can legitimately run past 32 links and still settle โ shrinking a heading 1px at a time from 64px to 18px renders 47 times, and drawing 400 rows 10 at a time is 40 links.
|
|
2842
3080
|
|
|
2843
|
-
|
|
3081
|
+
A write from code โ an event handler, `$connectedCallback`, a `$stream` value, an `await` continuation in your own code โ ends the chain, and so does a pause of one task: the count starts again from zero. An element that reports its value from a microtask (Lit's `updated()`, for example) stays in the chain, so a loop through such an element is cut too. A cycle through `$watch` (an element writes `x`, `$watch: { x(v) { this.mode = v } }`, and the list reads `mode`) is cut, one link per render; the `$watch` limit does not catch it, because the handler's write comes back through a render rather than straight into another handler. A `$stream` restart's writes count as well: element write-backs that restart a stream a microtask apart are cut after about 50 renders (100 without the stream). A write-back that settles, such as the initial sync of an ordinary list's rows, adds one link and stops, and finite chains joined by `$watch` share the 100 links. The chain is reported once; the next write from code renders normally. DevTools receives `state:render-chain-limit`.
|
|
2844
3082
|
|
|
2845
|
-
A
|
|
3083
|
+
A single update that keeps producing work within itself โ writes made while its bindings are applied โ is stopped after 32 passes and reported once per chain: `updates did not settle after 32 passes`, with the paths (DevTools: `state:render-chain-limit` with `maxDepth: 32`).
|
|
2846
3084
|
|
|
2847
3085
|
### Values and the DOM are never rolled back
|
|
2848
3086
|
|
|
@@ -2850,46 +3088,55 @@ Every failure mode reports and continues; nothing already applied is reverted:
|
|
|
2850
3088
|
|
|
2851
3089
|
| Mechanism | Limit | On exceeding |
|
|
2852
3090
|
|---|---|---|
|
|
2853
|
-
|
|
|
2854
|
-
| `$watch`
|
|
3091
|
+
| Passes within one update | 32 | Stop that update; what it would still have rendered waits for the next change |
|
|
3092
|
+
| `$watch` / `$stream` restart chain | 32 | Skip the handlers and stream restarts that the over-limit writes would fire; the batch's other handlers still run |
|
|
2855
3093
|
| Render write chain | 100 | Skip applying bindings for that batch |
|
|
3094
|
+
| Getter nesting | 128 | Throw `wcs/getter-depth-exceeded` |
|
|
2856
3095
|
| Binding apply failure | โ | Skip that one binding |
|
|
2857
3096
|
|
|
2858
|
-
|
|
3097
|
+
## Upgrading from 3.x
|
|
2859
3098
|
|
|
2860
|
-
|
|
3099
|
+
4.0 replaces the engine inside `@wcstack/state`. What you write against stays โ `<wcs-state>`, `data-wcs`, `{{ }}` and comment bindings, `for` / `if`, paths and path getters, the `$` APIs, `bind-component`, `mount=`, `enable-ssr` and the package entries. Every difference, with the reason for it and what to write instead, is in the [3.x โ 4.0 migration guide](https://github.com/wcstack/wcstack/blob/main/docs/migration-v4.md). The path is: move to 3.5 first and clear its `[wcs/v4-migration]` warnings and what the 3.5 lint reports, then move every `@wcstack/*` package to 4.0 at once โ `@wcstack/server` together with the client.
|
|
2861
3100
|
|
|
2862
|
-
|
|
2863
|
-
[@wcstack/state] [wcs/v4-migration] filter "uc" is removed in 4.0: write "upper" (its name since 3.2).
|
|
2864
|
-
See "Preparing for 4.0" in the @wcstack/state README.
|
|
2865
|
-
```
|
|
3101
|
+
In short:
|
|
2866
3102
|
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
| `bootstrapState({ enableMustache })`, `sameValueGuard`, `enableDirectionalInitialSync` | Page-wide options | Throw; each state tree declares them in `$behavior` | Keep them in 3.x. When you upgrade, move them to `$behavior` in each root and component state (a component does not inherit its host's; a volume may not declare one) |
|
|
2876
|
-
| An unknown `bootstrapState` key, or a value of another type (`null`, an array, a non-string tag name, a `tagNames` key other than `state` / `ssr`) | Ignored | Throws | Remove or fix it |
|
|
2877
|
-
| A state key `$behavior` | Plain data; 3.x does not read it | The state's behavior options | Warned only when its value differs from the option 3.x runs with, or has a shape 4.0 throws on |
|
|
2878
|
-
| A state key `$features` | Plain data | The add-ons the state needs | Warned only when 4.0 would throw on it (not an array of add-on names) |
|
|
2879
|
-
| A volume (`<wcs-state mount>`) that declares `$watch`, `$listKeys` or `$renderedCallback` (`$updatedCallback`) | Run relative to the mount path | The volume is not grafted (`console.error`) | Move them to the root state, with absolute paths |
|
|
2880
|
-
| A volume that injects root paths (`data-wcs="state.<key>: โฆ"`) | Works (3.1) | The volume is not grafted (`console.error`) | Read the root path in a root getter |
|
|
2881
|
-
| A volume that declares `$behavior` / `$features` | Plain data, grafted | The volume is not grafted (`console.error`) | Declare them on the root state |
|
|
3103
|
+
- **Removed:** `$scan` (fold in a `$watch` or an `$on` handler); the 3.2 old names โ the filters `inc` `dec` `fix` `uc` `lc` `cap` `rep` `rev` `pad` `null`, `$trackDependency` / `$untrackDependency`, `$updatedCallback` / `$streams`; the `substr` filter (`slice(start, start + length)`); the `bootstrapState()` options `debug`, `commentTextPrefix` and `enablePropagationContext`; `IStateElement.listPaths` / `getterPaths` / `setterPaths` / `nextVersion()`.
|
|
3104
|
+
- **Configuration:** `enableMustache`, `sameValueGuard` and `enableDirectionalInitialSync` move from `bootstrapState()` to the `$behavior` of each root state, and every package's `bootstrapXxx()` throws on an option it does not have ([Configuration](#configuration)).
|
|
3105
|
+
- **Events:** `on*:` handlers of the common bubbling events are delegated to the root, so `event.currentTarget` is the root; `#direct` opts a binding out ([Event Handling](#event-handling)).
|
|
3106
|
+
- **Lists:** writing a list element replaces the value at that position โ to move rows, assign a new array ([`$resolve`](#resolve--access-by-explicit-index)); `for:` takes no filters ([Loop](#loop-for)); `data-wcs` is removed from row and branch elements, and `outerHTML:` / `outerText:` cannot be used inside templates ([What the page walk binds](#what-the-page-walk-binds)).
|
|
3107
|
+
- **Volumes:** a volume no longer runs `$watch`, `$listKeys` or `$renderedCallback`, and takes no injections ([`mount=`](#mounting-additional-state-mount)).
|
|
3108
|
+
- **SSR:** the output of a 3.x server is rendered again on the client ([Server-Side Rendering](#server-side-rendering)).
|
|
3109
|
+
- **`$watch`:** a row watch fires without a `for:` or `$listKeys`, a watch on a row getter is evaluated for every row, and assigning a whole array fires only the rows that came in ([Watch](#watch-watch)).
|
|
3110
|
+
- **TypeScript:** import `defineState` from `@wcstack/state/define` โ imported from `@wcstack/state`, it keeps the engine in a bundle ([TypeScript Support](#typescript-support)).
|
|
2882
3111
|
|
|
2883
|
-
|
|
3112
|
+
`@wcstack/lint`, `@wcstack/typescript` and the VS Code extension (from 2.0.0) carry the 4.0 rules in the releases published with 4.0. Keep the 3.5-era versions for 3.x projects: the 4.0 rules report forms 3.x still accepts.
|
|
2884
3113
|
|
|
2885
|
-
|
|
3114
|
+
## Security
|
|
2886
3115
|
|
|
2887
|
-
|
|
3116
|
+
`@wcstack/state` puts values into the DOM; it does not sanitize them, and it never reads a value as markup. What it does read as markup is the page itself โ so the rules are about what reaches the page, and what you bind where.
|
|
2888
3117
|
|
|
2889
|
-
|
|
3118
|
+
**Markup you did not write must not reach the page walk.** When a `<wcs-state>` binds its root, it reads every `{{ โฆ }}`, every `data-wcs` attribute and every comment binding (`<!--@@: โฆ-->`) in the markup ([What the page walk binds](#what-the-page-walk-binds)). User content that a server template or your page code writes into the page's markup, rather than into state, is read the same way: a user name typed as `{{ apiKey }}` renders the state's `apiKey`, and a `data-wcs` attribute in a posted message binds โ client-side template injection (CSTI). Comment bindings are bound even with `$behavior.enableMustache: false`, so turning `{{ }}` off does not protect against it. There is no opt-out attribute in 4.0 (no `data-wcs-ignore`). Instead:
|
|
3119
|
+
|
|
3120
|
+
- Put user content in **state**, and bind it with `textContent:` (or `{{ }}` / a comment binding, which are text) โ a value is never read as markup.
|
|
3121
|
+
- Bind user HTML with `innerHTML:` / `html:` only after sanitizing it. The HTML sinks (`innerHTML:`, `outerHTML:`, `srcdoc:`, `html:`) pass every value that is not already a `TrustedHTML` through the policy you install (`setTrustedTypesPolicy()`, or `globalThis[Symbol.for("wcstack.trustedTypes.policy")]`), with or without Trusted Types in the browser โ so an installed sanitizer always runs. This package never signs a value with an identity policy ([docs/csp.md ยง7](../../docs/csp.md)).
|
|
3122
|
+
- Do not let a server template or your own code write user content into the page's markup: deliver it as data (in the state, or fetched into it). HTML escaping is not enough โ it leaves `{{ }}` as it is.
|
|
3123
|
+
|
|
3124
|
+
**Values are not checked.** A `javascript:` URL bound to `href:`, `src:` or `attr.href:`, and a handler string bound to `attr.onclick:` (any `attr.on*:`), are written as they are. Validate URLs yourself, and run the page under a Content-Security-Policy without `unsafe-inline` in `script-src`, which keeps both from running ([docs/csp.md](../../docs/csp.md)).
|
|
3125
|
+
|
|
3126
|
+
**Keep the comments in SSR output.** Text bindings and the row and branch markers of `@wcstack/server`'s output are comments. Removing them in a later step (html-minifier's `removeComments` and the like) breaks hydration, and a `{{ }}` inside a server-rendered value may then be read as a binding on the client.
|
|
2890
3127
|
|
|
2891
3128
|
## Configuration
|
|
2892
3129
|
|
|
3130
|
+
Configuration is split by what it decides:
|
|
3131
|
+
|
|
3132
|
+
| Where | What it decides | When it is read |
|
|
3133
|
+
|---|---|---|
|
|
3134
|
+
| `bootstrapState({ โฆ })` | How this page spells the markup โ the tag names, the binding attribute, the anchor comments โ and the default locale | Once, page-wide, when the elements are defined |
|
|
3135
|
+
| `$behavior` in a state | How that state tree behaves โ `{{ }}`, the same-value guard, direction-aware initial sync | When that state is loaded |
|
|
3136
|
+
| `$features` in a state (and `features=` on the root, with the split auto entry) | The add-ons that state needs | When that state is loaded |
|
|
3137
|
+
|
|
3138
|
+
### `bootstrapState()` options
|
|
3139
|
+
|
|
2893
3140
|
Pass a partial configuration object to `bootstrapState()`:
|
|
2894
3141
|
|
|
2895
3142
|
```javascript
|
|
@@ -2909,20 +3156,49 @@ All options with defaults:
|
|
|
2909
3156
|
| `bindAttributeName` | `'data-wcs'` | Binding attribute name |
|
|
2910
3157
|
| `tagNames.state` | `'wcs-state'` | State element tag name |
|
|
2911
3158
|
| `tagNames.ssr` | `'wcs-ssr'` | Tag name of the SSR hydration-data element |
|
|
2912
|
-
| `
|
|
2913
|
-
| `
|
|
2914
|
-
| `
|
|
2915
|
-
| `enableDirectionalInitialSync` | `true` | Direction-aware binding authority (`#init=` / `#sync=` binding modifiers) โ see [Binding Authority](#binding-authority-init--sync). Default on; set `false` to opt out. Moves to the state's `$behavior` in 4.0 (3.5 warns) |
|
|
2916
|
-
| `enablePropagationContext` | `true` | Causal propagation tracking across bindings (echo/diamond loop prevention). Default on; set `false` to opt out. Removed in 4.0 (3.5 warns) |
|
|
3159
|
+
| `commentForPrefix` | `'wcs-for'` | Text of the comment a `for:` template leaves in its place as its anchor |
|
|
3160
|
+
| `commentIfPrefix` / `commentElseIfPrefix` / `commentElsePrefix` | `'wcs-if'` / `'wcs-elseif'` / `'wcs-else'` | Texts of the anchor comments of an `if:` / `elseif:` / `else:` chain |
|
|
3161
|
+
| `locale` | `<html lang>` (read when the module is evaluated), else `'en'` | Locale for the locale-dependent filters (`locale` / `date` / `time` / `datetime`) โ see [Locale](#locale) |
|
|
2917
3162
|
| `enableContractAnalyzer` | `false` | Opt-in dev-time contract analyzer (exposes `analyzeContract`) |
|
|
2918
|
-
| `sameValueGuard` | `true` | Drop a primitive write whose value is `Object.is`-equal to the current one before anything is enqueued โ bindings and `$watch` effectively fire on change only; reference types always pass. `false` lets equal writes through and makes `$watch`'s `prev` `undefined`. Moves to the state's `$behavior` in 4.0 (3.5 warns) |
|
|
2919
3163
|
|
|
2920
|
-
4.0
|
|
3164
|
+
`bootstrapState()` throws on an option it does not have โ one 4.0 moved to `$behavior` or removed included โ on a value whose type differs from the default (`null`, or an array where an object is expected), on a name under `tagNames` other than `state` / `ssr`, and on a tag name that is not a string (`#44`). It checks every option before applying any, so nothing is applied when it throws. An `undefined` value is skipped (`bootstrapState({ locale: maybeLocale })`).
|
|
3165
|
+
|
|
3166
|
+
```
|
|
3167
|
+
[@wcstack/state] bootstrapState: "enableMustache" is not one of its options, or not of the option's type. 4.0 moved it to the state's $behavior.
|
|
3168
|
+
```
|
|
3169
|
+
|
|
3170
|
+
Every package's `bootstrapXxx()` โ `bootstrapRouter`, `bootstrapFetch`, `bootstrapAutoloader`, โฆ โ applies the same rule. Pages that only load the `/auto` entries call them without options: they keep the defaults, and set the locale through `<html lang>`. `getConfig()` returns the current configuration (read-only).
|
|
3171
|
+
|
|
3172
|
+
### `$behavior`
|
|
3173
|
+
|
|
3174
|
+
How a state tree behaves is declared by its state, not by the page:
|
|
3175
|
+
|
|
3176
|
+
```javascript
|
|
3177
|
+
export default {
|
|
3178
|
+
$behavior: { enableMustache: false, sameValueGuard: false },
|
|
3179
|
+
count: 0,
|
|
3180
|
+
};
|
|
3181
|
+
```
|
|
3182
|
+
|
|
3183
|
+
| Key | Default | Description |
|
|
3184
|
+
|---|---|---|
|
|
3185
|
+
| `enableMustache` | `true` | `{{ }}` in text is a binding ([Mustache Syntax](#mustache-syntax)). `false` leaves the braces as text; comment bindings are bound either way |
|
|
3186
|
+
| `sameValueGuard` | `true` | Drop a primitive write whose value is `Object.is`-equal to the current one before anything is enqueued โ bindings and `$watch` effectively fire on change only; reference types always pass. `false` lets equal writes through and makes `$watch`'s `prev` `undefined` |
|
|
3187
|
+
| `enableDirectionalInitialSync` | `true` | Direction-aware binding authority (`#init=` / `#sync=` binding modifiers) โ see [Binding Authority](#binding-authority-init--sync). `false` opts out: `#init=` / `#sync=` throw, and state wins every initial sync |
|
|
3188
|
+
|
|
3189
|
+
- Each key is a boolean. An unknown key, a value that is not a boolean, or a `$behavior` that is not an object โ `null` and an array included โ throws (`#44`).
|
|
3190
|
+
- It applies to the tree of the `<wcs-state>` that declares it, its volumes included. **Each root state declares its own** โ the page's root, a root `<wcs-state>` in a shadow root, a mounted component, a DCC: nothing is inherited from a host. A volume may not declare one ([`mount=`](#mounting-additional-state-mount)).
|
|
3191
|
+
- A re-set (`setInitialState()` on an initialized element) may not change it: that throws `#45` โ create the element again. A re-set state without `$behavior` is compared with the defaults, so repeat the declaration in it.
|
|
3192
|
+
- It works on `/auto` pages and in JSON states (`"$behavior": { "enableMustache": false }`). Under SSR the server reads the same state, so server and client agree.
|
|
3193
|
+
|
|
3194
|
+
### `$features`
|
|
3195
|
+
|
|
3196
|
+
`$features: ["temporal", "formats"]` names the add-ons a state needs. The split auto entry loads the missing ones before it builds the state; the other entries check that they are installed, and fail the state with `[wcs/feature-not-installed]` otherwise. A value that is not an array throws `#46`, and a volume may not declare one. The names, and the root `features=` attribute that the split auto entry reads before any state: [The split auto entry](#the-split-auto-entry).
|
|
2921
3197
|
|
|
2922
3198
|
### Locale
|
|
2923
3199
|
|
|
2924
|
-
Four filters format by locale โ `locale`, `date`, `time`, `datetime
|
|
2925
|
-
`
|
|
3200
|
+
Four filters format by locale โ `locale`, `date`, `time`, `datetime` (the `formats` add-on). They read
|
|
3201
|
+
the page locale (`getConfig().locale`), which **defaults to `<html lang>`**:
|
|
2926
3202
|
|
|
2927
3203
|
```html
|
|
2928
3204
|
<html lang="ja-JP">
|
|
@@ -2931,12 +3207,13 @@ Four filters format by locale โ `locale`, `date`, `time`, `datetime`. They rea
|
|
|
2931
3207
|
|
|
2932
3208
|
Nothing else is needed; `<html lang>` is the standard place to record a page's
|
|
2933
3209
|
language, and making it the default keeps one source of truth. It also means the
|
|
2934
|
-
CDN one-liner can set the locale
|
|
2935
|
-
|
|
2936
|
-
`bootstrapState({ locale })` still wins
|
|
2937
|
-
and
|
|
3210
|
+
CDN one-liner, which takes no options, can set the locale. `<html lang>` is read when
|
|
3211
|
+
the module is evaluated, so write it in the markup. An explicit
|
|
3212
|
+
`bootstrapState({ locale })` still wins. A tag `Intl` does not take (`<html lang="en_US">`)
|
|
3213
|
+
is reported once (`#48`) and the filters use `"en"`, rather than throwing inside `Intl`;
|
|
3214
|
+
`getConfig().locale` keeps returning what was set.
|
|
2938
3215
|
|
|
2939
|
-
**Changing
|
|
3216
|
+
**Changing the locale later does not re-render anything.** It is a global
|
|
2940
3217
|
setting, not state, so it is not part of the dependency graph. The filters do
|
|
2941
3218
|
read it on every application rather than capturing it when the binding is built,
|
|
2942
3219
|
which means a binding that re-renders for its own reasons will pick up the new
|
|
@@ -2965,22 +3242,19 @@ CSP all resolve against it), and two alternatives were weighed โ the router re
|
|
|
2965
3242
|
Read [docs/i18n-design.md](../../docs/i18n-design.md) ยง9-1 before choosing a
|
|
2966
3243
|
different shape; `examples/router-i18n` is the reference layout.
|
|
2967
3244
|
|
|
2968
|
-
>
|
|
2969
|
-
> `docs/architecture-hardening/`.
|
|
2970
|
-
>
|
|
2971
|
-
> wires do the causal bookkeeping) โ with the flag kept as a permanent opt-out.
|
|
2972
|
-
> `enableDirectionalInitialSync` also defaults **on**: it assigns per-property
|
|
3245
|
+
> `$behavior.enableDirectionalInitialSync` and `enableContractAnalyzer` are **architecture-hardening**
|
|
3246
|
+
> features; their normative reference is `docs/architecture-hardening/`.
|
|
3247
|
+
> `enableDirectionalInitialSync` defaults **on**: it assigns per-property
|
|
2973
3248
|
> initial-sync authority (an output-only `wcBindable` member reads its initial value
|
|
2974
|
-
> elementโstate; two-way / input members keep stateโelement)
|
|
2975
|
-
>
|
|
2976
|
-
> echo-capable two-way wires), and the flag is a permanent opt-out. `enableContractAnalyzer`
|
|
3249
|
+
> elementโstate; two-way / input members keep stateโelement), and the key is a permanent
|
|
3250
|
+
> opt-out. `enableContractAnalyzer`
|
|
2977
3251
|
> is opt-in (default `false`, zero runtime cost when off); when on, the exported
|
|
2978
3252
|
> `analyzeContract()` API reports drift between a live `static wcBindable` surface and
|
|
2979
3253
|
> a sidecar manifest for dev-time diagnostics.
|
|
2980
3254
|
|
|
2981
3255
|
## Testing Your Page
|
|
2982
3256
|
|
|
2983
|
-
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
|
|
3257
|
+
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.
|
|
2984
3258
|
|
|
2985
3259
|
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.
|
|
2986
3260
|
|
|
@@ -3049,11 +3323,10 @@ it("renders, re-renders, and runs handlers", async () => {
|
|
|
3049
3323
|
|
|
3050
3324
|
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()`.
|
|
3051
3325
|
|
|
3052
|
-
- `getBindingsReady(root)` resolves once
|
|
3326
|
+
- `getBindingsReady(root)` resolves once the bindings under `root` (a `document` or a shadow root) are built, and rejects if the root's `<wcs-state>` failed to initialize โ a root that never loaded reports the failure instead of "ready" ([Initialization failures](#initialization-failures)). It does not wait for `$connectedCallback`; await `connectedCallbackPromise` for that.
|
|
3053
3327
|
- Updates settle on the microtask queue; a single `setTimeout(0)` after a write is enough.
|
|
3054
3328
|
- `state.items = [...state.items, "cherry"]` is the reactive form โ `state.items.push()` is not observed (same rule as in handlers).
|
|
3055
3329
|
- 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.
|
|
3056
|
-
- 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.
|
|
3057
3330
|
|
|
3058
3331
|
### 2. Bare Node (no vitest)
|
|
3059
3332
|
|
|
@@ -3096,10 +3369,10 @@ it("matches the rendered snapshot", async () => {
|
|
|
3096
3369
|
|
|
3097
3370
|
## TypeScript Support
|
|
3098
3371
|
|
|
3099
|
-
`defineState()` wraps your state object and provides type-safe `this` inside methods and getters โ with zero runtime cost (identity function).
|
|
3372
|
+
`defineState()` wraps your state object and provides type-safe `this` inside methods and getters โ with zero runtime cost (identity function). Import it from **`@wcstack/state/define`**, which carries `defineState` and the types and nothing else (69 B):
|
|
3100
3373
|
|
|
3101
3374
|
```typescript
|
|
3102
|
-
import { defineState } from '@wcstack/state';
|
|
3375
|
+
import { defineState } from '@wcstack/state/define';
|
|
3103
3376
|
|
|
3104
3377
|
export default defineState({
|
|
3105
3378
|
count: 0,
|
|
@@ -3117,6 +3390,8 @@ export default defineState({
|
|
|
3117
3390
|
});
|
|
3118
3391
|
```
|
|
3119
3392
|
|
|
3393
|
+
`@wcstack/state` and `/core` export `defineState` too, but a bundler cannot drop the engine from those entries: a state file that imports `defineState` from `@wcstack/state` keeps the whole engine (about 21 KB gzip) in its bundle. That is harmless where the bundle runs the engine anyway, and wasteful where the state file is bundled on its own (the runtime loaded from a CDN, a type-check or test build) โ so prefer `/define`.
|
|
3394
|
+
|
|
3120
3395
|
Utility types `WcsPaths<T>` and `WcsPathValue<T, P>` are also exported for advanced use cases. See [docs/define-state.md](docs/define-state.md) for full documentation.
|
|
3121
3396
|
|
|
3122
3397
|
`defineState()` types the state file. To carry those types into the HTML, [`@wcstack/typescript`](../typescript/README.md) adds two CLIs: `wcs-schema` writes the `stateSchema` sidecar that `@wcstack/lint` and the VS Code extension validate `data-wcs` paths against, and `wcs-tsc` runs the TypeScript compiler over inline `<script type="module">` state. The whole story is in [docs/typescript.md](../../docs/typescript.md).
|
|
@@ -3125,140 +3400,98 @@ Utility types `WcsPaths<T>` and `WcsPathValue<T, P>` are also exported for advan
|
|
|
3125
3400
|
|
|
3126
3401
|
### `bootstrapState()`
|
|
3127
3402
|
|
|
3128
|
-
Initialize the state system. Registers `<wcs-state>` custom element and sets up DOM content loaded handler.
|
|
3129
|
-
|
|
3130
3403
|
```javascript
|
|
3131
3404
|
import { bootstrapState } from '@wcstack/state';
|
|
3132
|
-
bootstrapState();
|
|
3405
|
+
bootstrapState(config?, registry?);
|
|
3133
3406
|
```
|
|
3134
3407
|
|
|
3408
|
+
Installs every add-on (from `@wcstack/state`; the `/core` entry's `bootstrapState()` installs none โ call `installFeatures([...])` first), applies `config` ([`bootstrapState()` options](#bootstrapstate-options)), and registers `<wcs-state>` โ and the installed add-ons' tags, `<wcs-ssr>` โ in `registry`: the global `customElements` by default, or a scoped `CustomElementRegistry`, which does not inherit the global one's definitions. The `/auto` entries do the same with no options.
|
|
3409
|
+
|
|
3135
3410
|
### Other exports
|
|
3136
3411
|
|
|
3137
3412
|
| Export | Description |
|
|
3138
3413
|
|---|---|
|
|
3139
|
-
| `getBindingsReady(root)` | Resolves once
|
|
3140
|
-
| `buildBindings(root)` |
|
|
3414
|
+
| `getBindingsReady(root)` | Resolves once the bindings under `root` (a `document` or a shadow root) are built, without waiting for `$connectedCallback`; rejects if the root's `<wcs-state>` failed to initialize โ see [Initialization failures](#initialization-failures) |
|
|
3415
|
+
| `buildBindings(root)` | Waits for the bindings under a `document` or `ShadowRoot`, the same as `getBindingsReady(root)`: a root's bindings are built when its `<wcs-state>` has loaded its state |
|
|
3141
3416
|
| `getConfig()` | The current configuration (read-only view) |
|
|
3142
|
-
| `defineState(obj)` | Identity function that types `this` inside methods and getters โ see [TypeScript Support](#typescript-support) |
|
|
3417
|
+
| `defineState(obj)` | Identity function that types `this` inside methods and getters โ see [TypeScript Support](#typescript-support). In a state file, import it from `@wcstack/state/define` |
|
|
3418
|
+
| `setTrustedTypesPolicy(policy)` / `getTrustedTypesPolicy()` / `TRUSTED_TYPES_POLICY_SLOT` | The Trusted Types policy the HTML sinks use โ see [Security](#security) and [docs/csp.md ยง7](../../docs/csp.md) |
|
|
3143
3419
|
| `VERSION` | The package version; stamped into `<wcs-ssr>` and compared on hydration |
|
|
3144
|
-
| `
|
|
3420
|
+
| `Ssr` | The `<wcs-ssr>` element class (`ISsrElement`: `version`, `stateData`, `templates`, `getTemplate()`, `verifyVersion()`; `hydrateProps` is always empty) |
|
|
3421
|
+
| `getWcsManifest()` / `WCS_MANIFEST_VERSION` | Machine-readable manifest of the binding syntax, built-in filters, reserved names, the `$behavior` options (`behaviorOptions`) and the add-on names (`features`) โ derived from the implementation, consumed by `@wcstack/lint` and the VS Code extension |
|
|
3145
3422
|
| `builtinFilterMeta` | Argument and result metadata for every built-in filter |
|
|
3423
|
+
| `builtinFilterAliases` | Kept for the tools that read it; empty (4.0 has no filter aliases) |
|
|
3146
3424
|
| `analyzeContract()` | Dev-time contract analyzer; a no-op unless `enableContractAnalyzer` is on |
|
|
3147
3425
|
|
|
3148
|
-
Subpath entries for tooling: `@wcstack/state/parser` (the `data-wcs` parser as a DOM-free pure function), `@wcstack/state/manifest`, and `@wcstack/state/wcs-manifest.json` (the manifest as a prebuilt JSON file).
|
|
3426
|
+
`@wcstack/state/core` exports `bootstrapState`, `installFeatures`, `getBindingsReady`, `buildBindings`, `getConfig`, the Trusted Types functions, `defineState` and `VERSION`; each `@wcstack/state/features/<name>` exports its add-on as the default export. Subpath entries for tooling: `@wcstack/state/parser` (the `data-wcs` parser as a DOM-free pure function), `@wcstack/state/manifest`, and `@wcstack/state/wcs-manifest.json` (the manifest as a prebuilt JSON file). The type `IStateElement` is exported too.
|
|
3149
3427
|
|
|
3150
3428
|
### `<wcs-state>` Element
|
|
3151
3429
|
|
|
3152
3430
|
| Attribute | Description |
|
|
3153
3431
|
|---|---|
|
|
3154
3432
|
| `mount` | Static tree path to graft this state onto the root tree as a **volume** (v2 โ replaces the removed `name` attribute; one state tree per root) |
|
|
3155
|
-
| `state` | ID of a `<script type="application/json">`
|
|
3433
|
+
| `state` | ID of a `<script type="application/json">` โ looked up in the element's own root first (a shadow root), then in the document; only a JSON script of that id counts |
|
|
3156
3434
|
| `src` | URL to `.json` or `.js` file |
|
|
3157
3435
|
| `json` | Inline JSON string |
|
|
3158
3436
|
| `bind-component` | Property name for web component binding |
|
|
3159
3437
|
| `enable-ssr` | Opt into SSR: the server emits `<wcs-ssr>` hydration data for this state and the client hydrates from it instead of re-rendering โ see [Server-Side Rendering](#server-side-rendering) |
|
|
3438
|
+
| `features` | On the document's root `<wcs-state>`, read only by the split auto entry: the add-ons to load before `<wcs-state>` is defined โ see [The split auto entry](#the-split-auto-entry) |
|
|
3160
3439
|
|
|
3161
3440
|
### IStateElement
|
|
3162
3441
|
|
|
3163
3442
|
| Property / Method | Description |
|
|
3164
3443
|
|---|---|
|
|
3165
|
-
| `initializePromise` | Resolves
|
|
3166
|
-
| `connectedCallbackPromise` | Resolves once `connectedCallback` has completed (state loaded, `$connectedCallback` run) โ what the testing recipes await. A
|
|
3167
|
-
| `
|
|
3168
|
-
| `
|
|
3169
|
-
| `
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
| `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 |
|
|
3173
|
-
| `nextVersion()` | Increment and return version number |
|
|
3444
|
+
| `initializePromise` | Resolves once the state is loaded and the bindings are built โ and also **when initialization fails**, so one element's failure never blocks the rest of the page's bindings; the error is delivered on `connectedCallbackPromise` |
|
|
3445
|
+
| `connectedCallbackPromise` | Resolves once `connectedCallback` has completed (state loaded, bindings built, `$connectedCallback` run) โ what the testing recipes await. A root element that fails to initialize **rejects** it with the original error, unwrapped, and so does one whose `$connectedCallback` fails, a DCC definition and a component mount that fail; a volume always resolves it. See [Initialization failures](#initialization-failures) |
|
|
3446
|
+
| `createState(mutability, callback)` | Create a state proxy (`"readonly"` or `"writable"`); throws `state is not initialized` (`#15`) before the state is loaded |
|
|
3447
|
+
| `createStateAsync(mutability, callback)` | Async version of `createState`. A readonly proxy stays readonly across the callback's `await`s, and refuses the writes of `$setAll` and `$resolve(path, indexes, value)` too |
|
|
3448
|
+
| `setInitialState(state)` | Set state programmatically โ see below |
|
|
3449
|
+
|
|
3450
|
+
`setInitialState(state)` before initialization supplies the initial state. On an initialized element it replaces the whole state and re-applies every binding to the new state before it returns. A re-set is not a write: it fires no `$watch` handler and no `$renderedCallback`. A path the new state lacks reads `undefined`, at any depth: a binding to it is emptied (an element input keeps its own value, as for any `undefined` value), a list whose key the new state lacks renders no rows, and a getter that reads it gets `undefined`. A top-level key the old state had stays that way until it is written; a key no state had fails to read, as anywhere (`#301`). This is kept on the element: the new state object is not written to, so a frozen state (Immer, Redux Toolkit) can be re-set too. One gap: a getter on a class state's prototype is not enumerable, so when the new state drops it, the bindings to it fail to apply (reported, their text left as it was). A list that cannot be read (its getter throws) is reported as its `for` failure, and the re-set goes on. 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. It throws, and leaves the old state in place, when the new state's `$behavior` differs (`#45`) or a declaration has the wrong shape. It 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 component (`bind-component`) or DCC definition, on a tree with grafted volumes or mounted components, and on an element that failed to initialize (`#14`) โ such an element cannot be re-armed; remove it and create a new one. A root whose `$connectedCallback` failed after its bindings were built can be re-set. After a re-set, `Object.keys(this)`, `in`, `delete` and `JSON.stringify(this)` still see the old state object (a known limitation of this version).
|
|
3174
3451
|
|
|
3175
3452
|
## Architecture
|
|
3176
3453
|
|
|
3177
3454
|
```
|
|
3178
|
-
bootstrapState()
|
|
3179
|
-
|
|
3455
|
+
bootstrapState(config?, registry?)
|
|
3456
|
+
โโโ installFeatures(every add-on) // @wcstack/state and /auto; /core installs none
|
|
3457
|
+
โโโ setConfig(config) // checks every option first, then applies them
|
|
3458
|
+
โโโ define(registry) // <wcs-state> (+ the add-ons' tags), the binder
|
|
3180
3459
|
|
|
3181
3460
|
<wcs-state> connectedCallback
|
|
3182
|
-
โโโ
|
|
3183
|
-
โ โโโ
|
|
3184
|
-
โ โโโ
|
|
3185
|
-
โ
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
โโโ waitForStateInitialize() // Wait for all <wcs-state> initializePromise
|
|
3194
|
-
โโโ convertMustacheToComments() // {{ }} โ comment nodes
|
|
3195
|
-
โโโ collectStructuralFragments() // Collect for/if templates
|
|
3196
|
-
โโโ initializeBindings() // Walk DOM, parse data-wcs, set up bindings
|
|
3461
|
+
โโโ taken by an add-on (scopes) โ not a root:
|
|
3462
|
+
โ โโโ volume (mount=) // graft its state onto the root tree at the mount path
|
|
3463
|
+
โ โโโ DCC definition // define the tag; each instance has a root of its own
|
|
3464
|
+
โ โโโ component (bind-component) // an engine of its own, wired to the host's
|
|
3465
|
+
โโโ root:
|
|
3466
|
+
โโโ load the state // state= / src= / json= / inline script / setInitialState()
|
|
3467
|
+
โโโ load or check $features
|
|
3468
|
+
โโโ new Engine(state) // $behavior, declarations, accessors
|
|
3469
|
+
โโโ mount(engine, root) // walk the root once: plans, views, bindings โ and render
|
|
3470
|
+
โโโ bindings built // initializePromise, getBindingsReady()
|
|
3471
|
+
โโโ $connectedCallback // then connectedCallbackPromise
|
|
3197
3472
|
```
|
|
3198
3473
|
|
|
3199
3474
|
### Reactivity Flow
|
|
3200
3475
|
|
|
3201
|
-
1.
|
|
3202
|
-
2.
|
|
3203
|
-
3.
|
|
3204
|
-
4.
|
|
3476
|
+
1. A write through the state proxy (`this["user.name"] = โฆ`) resolves the path to a pattern and a row, and writes the data.
|
|
3477
|
+
2. The dependency walk dirties the getter occurrences that read it, and queues the bindings on that path and under it.
|
|
3478
|
+
3. On a microtask the drain re-syncs the lists, renders their rows, and applies the queued bindings โ through the view-transition arbiter, when the page has one.
|
|
3479
|
+
4. After the drain: `$renderedCallback`, the binding failures, then the add-ons (`$watch` handlers, `$stream` restarts).
|
|
3205
3480
|
|
|
3206
3481
|
### State Address System
|
|
3207
3482
|
|
|
3208
|
-
|
|
3483
|
+
A path like `users.*.name` is addressed in two parts:
|
|
3484
|
+
|
|
3485
|
+
- **Pattern** โ one per path spelled with `*` (`users.*.name`): its segments, its depth (the number of `*`), its parent, its getter or setter, the getters that read it, and the bindings registered on it at the root.
|
|
3486
|
+
- **List and row** โ a list for each array value a `for:`, a `$getAll` or a `$watch` reads, and a row for each element of it: the element, its position, its getter cache, the bindings inside it and its nested lists. A concrete path (`users.0.name`) is a pattern plus a row; dropping the row drops everything that hangs off it.
|
|
3209
3487
|
|
|
3210
|
-
|
|
3211
|
-
- **ListIndex** โ runtime loop index chain
|
|
3212
|
-
- **StateAddress** โ combination of PathInfo + ListIndex
|
|
3213
|
-
- **TreePath / AbsoluteStateAddress** โ a PathInfo pinned to the state element that owns the tree, plus its ListIndex. Mounted components and volumes translate their relative paths onto the host tree at this level; v2 has one tree per root, so an address carries no state name
|
|
3488
|
+
A mounted component runs an engine of its own, whose mounted keys read and write the host engine's paths; a volume's getters, setters and methods become accessors of the root tree at its mount path. There is one tree per root, so an address carries no state name.
|
|
3214
3489
|
|
|
3215
3490
|
## Performance
|
|
3216
3491
|
|
|
3217
|
-
|
|
3218
|
-
drivers (`e2e/bench/jsfb-verify.mjs`, `e2e/bench/memory-profile.mjs`) against the
|
|
3219
|
-
standard 1,000 / 10,000-row table page โ headless Chromium, medians, both
|
|
3220
|
-
implementations measured back-to-back in the same session. `@wcstack/state`
|
|
3221
|
-
passes the official keyed-mode classification while recycling row DOM through a
|
|
3222
|
-
bounded pool (up to 1,000 rows).
|
|
3492
|
+
The 4.0 engine is a rewrite, and its per-operation numbers have not yet been recorded with the repository's drivers: they are taken with the 4.0 build as part of the release. During development, the official [js-framework-benchmark](https://github.com/krausest/js-framework-benchmark) measured the 4.0 engine's CPU-weighted geometric mean at 1.08โ1.11, against 1.21โ1.25 for [`@wcstack/signals`](../signals/) and 1.51 for `@wcstack/state` 3.3.0 (lower is faster).
|
|
3223
3493
|
|
|
3224
|
-
|
|
3225
|
-
|---|---|---|
|
|
3226
|
-
| create 1,000 rows | 25.2 | 9.5 |
|
|
3227
|
-
| replace all 1,000 rows | 18.8 | 12.5 |
|
|
3228
|
-
| update every 10th of 10,000 | 11.4 | 4.7 |
|
|
3229
|
-
| select row | 0.1 | 0.4 |
|
|
3230
|
-
| swap 2 rows | 0.9 | 0.4 |
|
|
3231
|
-
| remove row | 2.8 | 0.6 |
|
|
3232
|
-
| append 1,000 to 10,000 | 48.6 | 14.2 |
|
|
3233
|
-
| clear 10,000 rows | 54.6 | 52.2 |
|
|
3234
|
-
|
|
3235
|
-
| Heap after forced GC (MB) | `@wcstack/state` | `@wcstack/signals` |
|
|
3236
|
-
|---|---|---|
|
|
3237
|
-
| page ready | 1.0 | 0.6 |
|
|
3238
|
-
| after create 1,000 | 5.6 | 3.5 |
|
|
3239
|
-
| after 5ร replace 1,000 | 6.4 | 3.7 |
|
|
3240
|
-
| after create 10,000 | 35.1 | 18.0 |
|
|
3241
|
-
| after create 10,000 + clear | 13.2 | 1.9 |
|
|
3242
|
-
|
|
3243
|
-
How to read this, honestly:
|
|
3244
|
-
|
|
3245
|
-
- Interactive operations (select / swap / remove) run in a few milliseconds or
|
|
3246
|
-
less, and clearing a huge list matches the signals implementation.
|
|
3247
|
-
- Creating and appending rows costs ~2.5โ3.5ร `@wcstack/signals`. That is the
|
|
3248
|
-
price of the declarative binding ledger this package builds per row โ the same
|
|
3249
|
-
ledger that powers `data-wcs` inspection, DevTools wiring, and SSR hydration.
|
|
3250
|
-
The two packages interoperate, so a hot list can be rendered with signals'
|
|
3251
|
-
`For` while the rest of the page stays declarative.
|
|
3252
|
-
- The heap retained after a clear is the bounded row pool that makes the next
|
|
3253
|
-
list population cheap.
|
|
3254
|
-
|
|
3255
|
-
Absolute numbers are from one development machine, taken at v1.21.6 + the
|
|
3256
|
-
clear-leak fix in PR#87. The v2.0 mount work was gated on the same drivers by
|
|
3257
|
-
same-session A/B runs and stayed within run-to-run noise
|
|
3258
|
-
([docs/state-mount-impl-plan.md](../../docs/state-mount-impl-plan.md) ยง2-2 and
|
|
3259
|
-
slice 27), so the table has not been re-taken; absolute values swing by ยฑ20%
|
|
3260
|
-
with machine state, and the drivers in `e2e/bench/` reproduce the comparison on
|
|
3261
|
-
your own hardware.
|
|
3494
|
+
To compare on your own hardware, run the drivers in `e2e/bench/` (`jsfb-verify.mjs`, `memory-profile.mjs`): they measure the standard 1,000 / 10,000-row table page in headless Chromium, both implementations back-to-back in the same session, as medians. Absolute values swing by ยฑ20% with machine state.
|
|
3262
3495
|
|
|
3263
3496
|
## Server-Side Rendering
|
|
3264
3497
|
|
|
@@ -3295,22 +3528,30 @@ const html = await renderToString(template, {
|
|
|
3295
3528
|
});
|
|
3296
3529
|
```
|
|
3297
3530
|
|
|
3298
|
-
That's it. The client-side `@wcstack/state`
|
|
3531
|
+
That's it. The client-side `@wcstack/state` finds the `<wcs-ssr>` element, takes the server's data from its snapshot, and binds the server's DOM where it is instead of rendering it again.
|
|
3299
3532
|
|
|
3300
3533
|
### How It Works
|
|
3301
3534
|
|
|
3302
3535
|
| Phase | What happens |
|
|
3303
3536
|
|-------|-------------|
|
|
3304
|
-
| **Server** | `renderToString()` runs your template in happy-dom, executes `$connectedCallback` (including `fetch()`), applies all bindings, and outputs rendered HTML with a `<wcs-ssr>` element
|
|
3305
|
-
| **Client** | `<wcs-state enable-ssr>` loads state from
|
|
3306
|
-
| **
|
|
3537
|
+
| **Server** | `renderToString()` runs your template in happy-dom, executes `$connectedCallback` (including `fetch()`), applies all bindings, and outputs rendered HTML with a `<wcs-ssr>` element before the `<wcs-state>`. Rows, branches and text bindings are marked with comments. `$watch` handlers do not run, and streams keep their `initial` value |
|
|
3538
|
+
| **Client** | `<wcs-state enable-ssr>` loads its state from its own source, takes the snapshot's data over it (getters, setters and methods stay), skips `$connectedCallback`, and binds the server's DOM in place. Rows and branches are adopted where they are โ a custom element in them is connected once and never disconnected and connected again โ and get their bindings. A server row that no longer fits its template is rendered again in its place; rows and branches the client does not render (fewer rows, another branch) go as soon as the page is bound. A volume adopts the server's data at its mount path |
|
|
3539
|
+
| **Version mismatch** | A snapshot whose `<wcs-ssr version>` has another major.minor is discarded with one `console.warn`, and the page renders on the client as if there were no server: the state loads from its own source, `$connectedCallback` runs, and the server's rows, branches and markers give way to the templates they were rendered from |
|
|
3307
3540
|
|
|
3308
3541
|
### What `enable-ssr` Does
|
|
3309
3542
|
|
|
3310
3543
|
| Context | Behavior |
|
|
3311
3544
|
|---------|----------|
|
|
3312
|
-
| **Server** (`renderToString`) | Generates `<wcs-ssr>` with
|
|
3313
|
-
| **Client** (hydration) | Reads `<wcs-ssr>`, restores
|
|
3545
|
+
| **Server** (`renderToString`) | Generates `<wcs-ssr>` with the version, the state's data as JSON, and the page-level templates |
|
|
3546
|
+
| **Client** (hydration) | Reads `<wcs-ssr>`, restores the data, skips `$connectedCallback`, adopts the server's rows and branches and binds them |
|
|
3547
|
+
|
|
3548
|
+
Rules:
|
|
3549
|
+
|
|
3550
|
+
- **Deploy `@wcstack/server` and the client together**, of the same major.minor. A 4.0 client renders the output of a 3.x server again on the client (`<wcs-ssr version="3.5.0"> does not match 4.0.0: its snapshot is discarded, and the page renders on the client from its own state.`) โ the page works, but what the server did is done again, and data that `$connectedCallback` fetches is fetched again. In the output of a 3.x server up to 3.5.2, a text binding outside the templates (`{{ }}` or `<!--@@: -->` at page level or in a Light DOM component's content) keeps only its path, so it loses its filters, and the warning says so; from 3.5.3 the markers keep the whole expression, except one an HTML comment cannot hold (with `--` in it, such as `|defaults('--')`), which keeps only its path. Purge HTML cached from a 3.x server when you switch. A 4.0 server with a 3.x client is not supported. The details are in the [migration guide ยง3.6](https://github.com/wcstack/wcstack/blob/main/docs/migration-v4.md).
|
|
3551
|
+
- **The snapshot is JSON.** `Date`, `Set`, `Map` and class instances do not survive it: they arrive as strings or plain objects and replace the client's values. Keep JSON values in SSR state and derive the rest in getters (`get created() { return new Date(this.createdIso); }`).
|
|
3552
|
+
- **Keep the comments in the output** โ removing them breaks hydration ([Security](#security)). Do not post-process the output based on its markers either: their format is not an API.
|
|
3553
|
+
- `outerHTML:` / `outerText:` are applied on the client, not on the server: the server output contains the element as written, so the value is not in the HTML that search engines or no-JS views see.
|
|
3554
|
+
- Children that a Light DOM custom element bound at page level renders from a value are not in the server output; the client renders them. Children you wrote stay in it.
|
|
3314
3555
|
|
|
3315
3556
|
See [`@wcstack/server` README](../server/README.md) for full API documentation.
|
|
3316
3557
|
|