@wcstack/state 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ The following are not missing features. **They do not exist by design.**
14
14
 
15
15
  - APIs for pulling variables out of state into components
16
16
  - Per-element binding objects that mediate state access
17
- - hooks
17
+ - hooks (`useState` / `useStore`-style — the `$connectedCallback` lifecycle callbacks are not that)
18
18
  - selectors
19
19
  - glue code that imports reactive primitives into component code
20
20
 
@@ -22,11 +22,11 @@ None of these exist by design.
22
22
 
23
23
  Why: this library does not put the UI-state coupling point inside JavaScript. State is not pulled into components. HTML refers to state through path strings. Elements do not own state, and state does not know elements. The only shared contract is the path.
24
24
 
25
- ## Do Not Compare This to Existing Frameworks
25
+ ## Where It Sits and When Not to Choose It
26
26
 
27
- This is not solving the same problem as React / Vue / Solid with a different syntax. **The premises are different.**
27
+ This is not React / Vue / Solid with a different syntax. Those put the coupling point between UI and state inside a component; this puts it in a path string. **The premises are different**, and a comparison only says something when it is made along the right axis.
28
28
 
29
- | What mainstream frameworks assume | What `@wcstack/state` assumes |
29
+ | What component frameworks assume | What `@wcstack/state` assumes |
30
30
  |---|---|
31
31
  | Components are the coupling point between UI and state | Path strings are the coupling point between UI and state |
32
32
  | JavaScript is the center of rendering | HTML and the DOM are the center |
@@ -34,7 +34,16 @@ This is not solving the same problem as React / Vue / Solid with a different syn
34
34
  | hooks / selectors / signals express subscriptions | Attributes and paths express bindings |
35
35
  | The whole app runs inside a framework execution model | A thin reactive layer is added on top of web standards |
36
36
 
37
- Before making a comparison chart, understand this difference in premises. These tools may live in the same ecosystem, but they cut the problem space very differently.
37
+ The nearer relatives are the **attribute-directive, no-build libraries** Alpine.js, petite-vue and their kind. They share the premise (attributes on plain HTML, no compiler) and differ on two points that decide the choice:
38
+
39
+ - **No expression language.** Those libraries put JavaScript expressions in attributes and evaluate them at runtime. `data-wcs` carries a path and a filter chain, nothing else; computation lives in path getters on the state. That is what lets a binding be checked statically (`@wcstack/lint`, the VS Code extension, `@wcstack/typescript`) and lets a page run under a strict CSP with no `unsafe-eval` ([docs/csp.md](../../docs/csp.md)).
40
+ - **It wires Web Components to each other.** The wc-bindable, command-token and event-token protocols and `bind-component` mounts connect elements that never import one another. The [I/O node packages](../../README.md#additional-packages) are what that buys.
41
+
42
+ **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.
43
+
44
+ **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) measures create / append at 2.5–3.5× [`@wcstack/signals`](../signals/), which interoperates with this package and is the better fit there; 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.
45
+
46
+ 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.
38
47
 
39
48
  ## First Principle: Path as the Universal Contract
40
49
 
@@ -95,6 +104,9 @@ That's it. No build, no bootstrap code, no framework.
95
104
  - **Declarative data binding** — `data-wcs` attribute for property / text / event / structural binding
96
105
  - **Reactive Proxy** — ES Proxy-based automatic DOM updates with dependency tracking
97
106
  - **Structural directives** — `for`, `if` / `elseif` / `else` via `<template>` elements
107
+ - **Volumes** — `<wcs-state mount="cart">` grafts a module onto the single state tree; bindings read it as `cart.…`
108
+ - **Row identity** — `$listKeys` keeps row DOM and row objects across refetched arrays
109
+ - **Wildcard aggregation** — `$getAll` / `$setAll` read and write across `items.*.price` without rebuilding the array
98
110
  - **Built-in filters** — 46 filters for formatting, comparison, arithmetic, date, and more
99
111
  - **Two-way binding** — automatic for `<input>`, `<select>`, `<textarea>`
100
112
  - **Web Component binding** — bidirectional state binding with Shadow DOM components
@@ -105,8 +117,10 @@ That's it. No build, no bootstrap code, no framework.
105
117
  - **Mustache syntax** — `{{ path|filter }}` in text nodes
106
118
  - **Multiple state sources** — JSON, JS module, inline script, API, attribute
107
119
  - **SVG support** — full binding support inside `<svg>` elements
108
- - **Lifecycle hooks** — `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback`, plus `$stateReadyCallback` for Web Components
109
- - **TypeScript support** — `defineState()` for typed state definitions with dot-path autocompletion ([details](docs/define-state.md))
120
+ - **Lifecycle hooks** — `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback` / `$errorCallback`, plus `$stateReadyCallback` for Web Components
121
+ - **Headless watch** — `$watch` fires on state changes whether or not the path is rendered
122
+ - **Diagnostics** — unresolved paths, index arity and getter cycles are reported with the same codes as `@wcstack/lint` and the VS Code extension
123
+ - **TypeScript support** — `defineState()` for typed state definitions with dot-path autocompletion ([details](docs/define-state.md)); `@wcstack/typescript` carries the same types into the HTML validator (`wcs-schema`) and type-checks inline state scripts (`wcs-tsc`) — see [docs/typescript.md](../../docs/typescript.md)
110
124
  - **Server-Side Rendering** — `enable-ssr` attribute + `@wcstack/server` for full SSR with automatic hydration
111
125
  - **Zero dependencies** — no runtime dependencies
112
126
 
@@ -233,7 +247,7 @@ There is **one state tree per root**. To split state across modules, mount a vol
233
247
  <div data-wcs="textContent: cart.total"></div>
234
248
  ```
235
249
 
236
- A volume may declare getters, `$watch`, `$listKeys`, `$updatedCallback`, and `$connectedCallback`/`$disconnectedCallback` — all relative to its mount path. Load order does not matter (a volume connected before the root is grafted when the root registers). Mount paths must be static (`*`, `$`, `#`, `@` are rejected).
250
+ A volume may declare getters, `$watch`, `$listKeys`, `$updatedCallback`, and `$connectedCallback`/`$disconnectedCallback` — all relative to its mount path. `$errorCallback` is root-only (a binding failure is reported once, to the tree's owner). Load order does not matter (a volume connected before the root is grafted when the root registers). Mount paths must be static (`*`, `$`, `#`, `@` are rejected). Changing `mount` after the element has initialized is not supported: the change is ignored with a console warning — remove the element and add a new one with the desired path.
237
251
 
238
252
  > **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.
239
253
 
@@ -348,6 +362,8 @@ Automatically enabled for:
348
362
 
349
363
  ### Binding Authority (`#init=` / `#sync=`)
350
364
 
365
+ **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.
366
+
351
367
  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 via `enableDirectionalInitialSync`):
352
368
 
353
369
  | Member declared in | Default authority | Effect |
@@ -359,6 +375,34 @@ For custom elements that declare `static wcBindable`, every prop binding resolve
359
375
 
360
376
  > **Authoring rule:** declare every settable member in **both** `properties` and `inputs`. A member declared only in `properties` is output-only — state→element writes are suppressed for the life of the binding, and the element's own initial value overwrites whatever the state seeded. (`@wcstack` I/O node Shells and DCC `$bindables` follow this rule.)
361
377
 
378
+ #### What the element writes back (`properties[].getter`)
379
+
380
+ 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`.
381
+
382
+ 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 runtime 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:
383
+
384
+ ```javascript
385
+ class YenInput extends HTMLElement {
386
+ static wcBindable = {
387
+ protocol: "wc-bindable", version: 1,
388
+ properties: [
389
+ // (a) the value itself is the detail — the protocol's recommendation; no getter needed
390
+ { name: "value", event: "yen-input:value-changed" },
391
+ // (b) the detail is an object, or the event is not a CustomEvent — say how to read it
392
+ // { name: "value", event: "yen-input:value-changed", getter: (e) => e.detail.value },
393
+ // { name: "value", event: "input", getter: (e) => e.target.value },
394
+ ],
395
+ inputs: [{ name: "value" }],
396
+ };
397
+ #onInput() {
398
+ // (a): dispatch the value, not a wrapper object
399
+ this.dispatchEvent(new CustomEvent("yen-input:value-changed", { detail: this.value, bubbles: true }));
400
+ }
401
+ }
402
+ ```
403
+
404
+ Whichever you pick, `element.value` and the value extracted from the event must be the same logical state (the protocol's *Producer State Consistency Invariant*): the initial sync reads the property, every later update reads the event. Both shapes are in use inside wcstack — `<wcs-fetch>`'s `loading` dispatches the boolean as `detail` with no `getter`, its `value` reads `detail.value` through one — and DCC `$bindables` declare `getter: (e) => e.target[name]` because a sub-path write has no single value to put in `detail`. The default itself is not going to change: it is normative for every wc-bindable adapter (`@wc-bindable/core`'s `bind()` and the framework adapters implement the same `e.detail`), and the protocol classes a different default as a breaking change requiring a new protocol identifier.
405
+
362
406
  Override the authority per binding with `#init=`:
363
407
 
364
408
  | Value | Initial sync | Allowed on |
@@ -902,6 +946,20 @@ export default {
902
946
 
903
947
  Getters that throw are not swallowed: the exception surfaces where the getter was evaluated (a binding apply, a `$watch` evaluation, or your own read).
904
948
 
949
+ #### Dependency tracking boundaries
950
+
951
+ Three rules decide what the dependency graph sees. None of them matters until you cross one, and when you do the symptom is a value that stops updating with no error — so they are collected here:
952
+
953
+ | Rule | What it looks like when crossed |
954
+ |---|---|
955
+ | **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"]` |
956
+ | **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 |
957
+ | **The same-value guard applies to primitives only.** A primitive write `Object.is`-equal to the current value is dropped before anything is enqueued; an object or array write always passes, even the same reference | Assigning the same string again fires nothing; assigning the same object again re-fires its bindings and `$watch` (`config.sameValueGuard`; a `semantics: "event"` property is exempt either way) |
958
+
959
+ 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 `$streams` fold — is left alone.
960
+
961
+ `$untrackDependency(fn)` applies the setter rule to a getter on purpose: reads inside `fn` are not tracked. `$trackDependency(path)` is the escape hatch for the first rule.
962
+
905
963
  ### Loop Index Variables (`$1`, `$2`, ...)
906
964
 
907
965
  Inside getters and event handlers, `this.$1`, `this.$2`, etc. provide the current loop iteration index (0-based value, 1-based naming):
@@ -1292,6 +1350,23 @@ customElements.define("user-card", UserCard);
1292
1350
  > (and on `this` inside getters/methods) speak the component's own vocabulary — paths are
1293
1351
  > translated onto the mount and the host row's indexes are prepended automatically.
1294
1352
 
1353
+ #### Exported getters (reading a component's getter from outside)
1354
+
1355
+ A mounted component's getters are **exported** at the mount point: **a read of a key the tree does not have is answered by the getter of the component mounted there. A key the tree does have wins. Private keys and methods are never visible.** With the `user-card` above, the host can bind `session`-level markup to the component's derived value:
1356
+
1357
+ ```html
1358
+ <user-card data-wcs="state: user"></user-card>
1359
+ <span data-wcs="textContent: user.display"></span> <!-- "Alice <alice@example.com>" — the component's getter -->
1360
+ ```
1361
+
1362
+ - Row mounts export per row: `$getAll("users.*.display")` and `text: .display` inside the same `for` read each row component's getter. Dependencies flow through: when `user.name` changes, everything that read `user.display` re-renders.
1363
+ - Accessors whose component-local path contains a wildcard, such as `get "children.*.label"()`, work inside the component but are **not exported**. Define `get label()` on a component mounted on each child row instead. Only accessors whose exported path has the mount point's wildcard count are exported.
1364
+ - The parent evaluates before the child component registers, so the first read may see `undefined`; the value converges as soon as the component mounts. Write derived expressions defensively (`(x ?? 0)`).
1365
+ - 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.
1366
+ - 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.
1367
+ - 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.
1368
+ - **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 ledger 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).
1369
+
1295
1370
  ### Standalone Web Component Injection (`__e2e__/single-component`)
1296
1371
 
1297
1372
  Even when a component is independent from outer host state, you can inject reactive state with `bind-component`.
@@ -2082,7 +2157,7 @@ All bindings work inside `<svg>` elements. Use `attr.*` for SVG attributes:
2082
2157
 
2083
2158
  ## Lifecycle Hooks
2084
2159
 
2085
- State objects can define `$connectedCallback`, `$disconnectedCallback`, and `$updatedCallback` for initialization, cleanup, and update lifecycle handling.
2160
+ State objects can define `$connectedCallback`, `$disconnectedCallback`, `$updatedCallback`, and `$errorCallback` for initialization, cleanup, update, and binding-failure handling.
2086
2161
 
2087
2162
  ```html
2088
2163
  <wcs-state>
@@ -2112,13 +2187,27 @@ State objects can define `$connectedCallback`, `$disconnectedCallback`, and `$up
2112
2187
  | `$connectedCallback` | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) |
2113
2188
  | `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
2114
2189
  | `$updatedCallback(paths, indexesListByPath)` | After updates are applied to live bindings | Yes (not awaited) |
2190
+ | `$errorCallback(error, info)` | After a drain in which a binding failed to apply — once per failed binding, after `$updatedCallback` | Yes (not awaited) |
2115
2191
 
2116
2192
  All hooks except `$disconnectedCallback` support `async` — you can use `async/await` in any of them. Since the reactive proxy detects every property assignment as a change, standard `async/await` with direct property updates is sufficient for asynchronous operations — loading flags, fetched data, and error messages are all just property assignments, without requiring additional abstractions for async state management.
2117
2193
 
2118
2194
  - `this` inside hooks is the state proxy with full read/write access
2119
2195
  - `$connectedCallback` is called **every time** the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
2120
2196
  - `$disconnectedCallback` is called synchronously — use it for cleanup such as clearing timers, removing event listeners, or releasing resources
2121
- - `$updatedCallback(paths, indexesListByPath)` receives the paths whose live bindings were applied in that drain. Unbound state writes do not invoke it or appear in `paths`. For wildcard updates, `indexesListByPath` contains the updated index sets. Can be `async`, but the return value is not awaited
2197
+ - `$updatedCallback(paths, indexesListByPath)` receives the paths whose live bindings were applied in that drain. Unbound state writes do not invoke it or appear in `paths`. For wildcard updates, `indexesListByPath` contains the updated index sets. Marker paths of mounted components (`#m…`) never appear in `paths` — a component's private keys stay private (DevTools shows them in its overlays view). Can be `async`, but the return value is not awaited
2198
+ - `$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:
2199
+
2200
+ ```js
2201
+ export default {
2202
+ user: null, loadError: "",
2203
+ get title() { return this.user.profile.name; }, // throws while user is null
2204
+ $errorCallback(error, { path }) {
2205
+ this.loadError = `${path}: ${error.message}`; // <p data-wcs="textContent: loadError">
2206
+ },
2207
+ };
2208
+ ```
2209
+
2210
+ The hook runs after the batch (after `$updatedCallback`), is not awaited, and an exception thrown inside it is reported to the console without breaking the drain. DevTools still receives every failure as `state:binding-apply-error` whether or not the hook exists. Root-only: a volume (`<wcs-state mount>`) declaring it is ignored. It does not cover `$watch` handlers (isolated and reported separately) or errors thrown by `$connectedCallback` / `$updatedCallback` (those fail loudly).
2122
2211
  - In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
2123
2212
 
2124
2213
  ## Transition animations
@@ -2153,7 +2242,7 @@ Only a batch that actually has bindings to apply is handed to the tag, so a writ
2153
2242
  When a wired path provably does not resolve against the state, you get one warning at binding time (at declaration time for `$watch`). The diagnostic codes are shared by the console, `@wcstack/lint`, and the VS Code extension:
2154
2243
 
2155
2244
  ```
2156
- [@wcstack/state] [wcs/binding-path-missing] Bound path "user.nmae" does not resolve on state "default":
2245
+ [@wcstack/state] [wcs/binding-path-missing] Bound path "user.nmae" does not resolve on the state tree:
2157
2246
  "nmae" is not declared. Did you mean "name"? Updates to this path will be silently
2158
2247
  dropped. Validate statically: npx @wcstack/lint <file>.
2159
2248
  ```
@@ -2237,12 +2326,14 @@ All options with defaults:
2237
2326
  |---|---|---|
2238
2327
  | `bindAttributeName` | `'data-wcs'` | Binding attribute name |
2239
2328
  | `tagNames.state` | `'wcs-state'` | State element tag name |
2329
+ | `tagNames.ssr` | `'wcs-ssr'` | Tag name of the SSR hydration-data element |
2240
2330
  | `locale` | `<html lang>`, else `'en'` | Locale for the locale-dependent filters (`locale` / `date` / `time` / `datetime`) — see [Locale](#locale) |
2241
2331
  | `debug` | `false` | Debug mode |
2242
2332
  | `enableMustache` | `true` | Enable `{{ }}` syntax |
2243
2333
  | `enableDirectionalInitialSync` | `true` | Direction-aware binding authority (`#init=` / `#sync=` binding modifiers) — see [Binding Authority](#binding-authority-init--sync). Default on; set `false` to opt out |
2244
2334
  | `enablePropagationContext` | `true` | Causal propagation tracking across bindings (echo/diamond loop prevention). Default on; set `false` to opt out |
2245
2335
  | `enableContractAnalyzer` | `false` | Opt-in dev-time contract analyzer (exposes `analyzeContract`) |
2336
+ | `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` |
2246
2337
 
2247
2338
  ### Locale
2248
2339
 
@@ -2274,6 +2365,22 @@ of the binding expression: `price|locale(fr-FR)`. For a page that switches
2274
2365
  language without reloading, see [docs/i18n-design.md](../../docs/i18n-design.md) —
2275
2366
  the short answer is that translations belong on a path, not in a filter.
2276
2367
 
2368
+ **Where i18n sits, and what was decided.** There is no i18n package and no live
2369
+ language switch, on purpose. A dictionary is an ES module chosen per locale and mounted as a
2370
+ volume (`<wcs-state mount="i18n" src="/i18n/state.js">`), then read as ordinary
2371
+ paths (`i18n.checkout.title`); the locale is decided **before** the page renders — from
2372
+ `<html lang>` for the filters, and from the URL for the router, where the locale
2373
+ lives in the `basename` (`/ja/…`) rather than in a route parameter. Switching
2374
+ language is therefore a real navigation to another basename, not a state write:
2375
+ the router intercepts links under its own basename only, so a `/:lang` parameter
2376
+ would silently keep the old language, and a live switch would need every locale-
2377
+ dependent module to re-evaluate. The `<base href>` that carries the basename has a
2378
+ real cost (page-fragment anchors, SVG fragment references, relative `src` under
2379
+ CSP all resolve against it), and two alternatives were weighed — the router reading
2380
+ `<html lang>` itself, and a per-link opt-out of interception — and left recorded.
2381
+ Read [docs/i18n-design.md](../../docs/i18n-design.md) §9-1 before choosing a
2382
+ different shape; `examples/router-i18n` is the reference layout.
2383
+
2277
2384
  > These three are **architecture-hardening** features; their normative reference is
2278
2385
  > `docs/architecture-hardening/`. `enablePropagationContext` defaults **on** — its
2279
2386
  > write-path cost is near-zero for one-way bindings (only echo-capable two-way
@@ -2428,6 +2535,8 @@ export default defineState({
2428
2535
 
2429
2536
  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.
2430
2537
 
2538
+ `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).
2539
+
2431
2540
  ## API Reference
2432
2541
 
2433
2542
  ### `bootstrapState()`
@@ -2439,6 +2548,21 @@ import { bootstrapState } from '@wcstack/state';
2439
2548
  bootstrapState();
2440
2549
  ```
2441
2550
 
2551
+ ### Other exports
2552
+
2553
+ | Export | Description |
2554
+ |---|---|
2555
+ | `getBindingsReady(root)` | Resolves once every binding under `root` (a `document` or a shadow root) is built; rejects if binding initialization fails |
2556
+ | `buildBindings(root)` | Build the bindings under a `document` or `ShadowRoot` explicitly — what the first `<wcs-state>` registered on a root schedules for it |
2557
+ | `getConfig()` | The current configuration (read-only view) |
2558
+ | `defineState(obj)` | Identity function that types `this` inside methods and getters — see [TypeScript Support](#typescript-support) |
2559
+ | `VERSION` | The package version; stamped into `<wcs-ssr>` and compared on hydration |
2560
+ | `getWcsManifest()` / `WCS_MANIFEST_VERSION` | Machine-readable manifest of the binding syntax, built-in filters and reserved names — derived from the implementation, consumed by `@wcstack/lint` and the VS Code extension |
2561
+ | `builtinFilterMeta` | Argument and result metadata for every built-in filter |
2562
+ | `analyzeContract()` | Dev-time contract analyzer; a no-op unless `enableContractAnalyzer` is on |
2563
+
2564
+ 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).
2565
+
2442
2566
  ### `<wcs-state>` Element
2443
2567
 
2444
2568
  | Attribute | Description |
@@ -2448,19 +2572,20 @@ bootstrapState();
2448
2572
  | `src` | URL to `.json` or `.js` file |
2449
2573
  | `json` | Inline JSON string |
2450
2574
  | `bind-component` | Property name for web component binding |
2575
+ | `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) |
2451
2576
 
2452
2577
  ### IStateElement
2453
2578
 
2454
2579
  | Property / Method | Description |
2455
2580
  |---|---|
2456
2581
  | `initializePromise` | Resolves when state is fully initialized |
2582
+ | `connectedCallbackPromise` | Resolves once `connectedCallback` has completed (state loaded, `$connectedCallback` run) — what the testing recipes await |
2457
2583
  | `listPaths` | Set of paths used in `for` loops |
2458
2584
  | `getterPaths` | Set of paths defined as getters |
2459
2585
  | `setterPaths` | Set of paths defined as setters |
2460
2586
  | `createState(mutability, callback)` | Create a state proxy (`"readonly"` or `"writable"`) |
2461
2587
  | `createStateAsync(mutability, callback)` | Async version of `createState` |
2462
2588
  | `setInitialState(state)` | Set state programmatically (before initialization) |
2463
- | `bindProperty(prop, descriptor)` | Define a property on the raw state object |
2464
2589
  | `nextVersion()` | Increment and return version number |
2465
2590
 
2466
2591
  ## Architecture
@@ -2470,11 +2595,14 @@ bootstrapState()
2470
2595
  └── registerComponents() // Register <wcs-state> custom element
2471
2596
 
2472
2597
  <wcs-state> connectedCallback
2473
- ├── _initializeBindWebComponent() // bind-component: get state from parent component
2474
- ├── _initialize() // Load state (state attr / src / json / script / API)
2475
- └── setStateElementByName() // Register to WeakMap<Node, Map<name, element>>
2476
- └── (first registration per rootNode)
2477
- └── queueMicrotask buildBindings()
2598
+ ├── one of, by placement:
2599
+ ├── _initializeDCC() // under a data-wc-definition host: define the DCC class
2600
+ ├── _initializeVolume() // mount=: graft this volume onto the root tree
2601
+ ├── _initializeBindWebComponent() // bind-component: alias the host's tree at the mount point
2602
+ └── _initialize() // root: load state (state attr / src / json / script / API)
2603
+ │ └── setStateElement() // Register to WeakMap<Node, IStateElement> — one tree per root
2604
+ │ └── (first registration per rootNode)
2605
+ │ └── queueMicrotask → buildBindings()
2478
2606
  ├── _callStateConnectedCallback() // Call $connectedCallback if defined
2479
2607
 
2480
2608
  buildBindings(root)
@@ -2498,7 +2626,7 @@ Paths like `users.*.name` are decomposed into:
2498
2626
  - **PathInfo** — static path metadata (segments, wildcard count, parent path)
2499
2627
  - **ListIndex** — runtime loop index chain
2500
2628
  - **StateAddress** — combination of PathInfo + ListIndex
2501
- - **AbsoluteStateAddress** — state name + StateAddress (for cross-state references)
2629
+ - **AbsolutePathInfo / 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
2502
2630
 
2503
2631
  ## Performance
2504
2632
 
@@ -2540,9 +2668,13 @@ How to read this, honestly:
2540
2668
  - The heap retained after a clear is the bounded row pool that makes the next
2541
2669
  list population cheap.
2542
2670
 
2543
- Absolute numbers are from one development machine (v1.21.6 + the clear-leak fix
2544
- in PR#87); the drivers in `e2e/bench/` reproduce the comparison on your own
2545
- hardware.
2671
+ Absolute numbers are from one development machine, taken at v1.21.6 + the
2672
+ clear-leak fix in PR#87. The v2.0 mount work was gated on the same drivers by
2673
+ same-session A/B runs and stayed within run-to-run noise
2674
+ ([docs/state-mount-impl-plan.md](../../docs/state-mount-impl-plan.md) §2-2 and
2675
+ slice 27), so the table has not been re-taken; absolute values swing by ±20%
2676
+ with machine state, and the drivers in `e2e/bench/` reproduce the comparison on
2677
+ your own hardware.
2546
2678
 
2547
2679
  ## Server-Side Rendering
2548
2680