wcstack 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.md CHANGED
@@ -23,6 +23,8 @@ Do not guess at syntax that is not documented here. wcstack has little presence
23
23
 
24
24
  Both are spelled out under [What does not work](#what-does-not-work).
25
25
 
26
+ This guide describes **`@wcstack/state` 4.0** and the packages released with it. Upgrading an existing 3.x app: read the [3.x → 4.0 migration guide](https://github.com/wcstack/wcstack/blob/main/docs/migration-v4.md) instead.
27
+
26
28
  ---
27
29
 
28
30
  ## Loading wcstack — single packages, or this package's SPA-core bundle
@@ -49,7 +51,18 @@ Loading the bundle alongside an individual package's `/auto` is safe: whichever
49
51
 
50
52
  If you want npm packages for local development, install the individual ones (`@wcstack/state`, `@wcstack/router`, …); this package publishes only the bundle and this guide.
51
53
 
52
- `@wcstack/state` also ships **split entries** for a page that deliberately leaves features out: import `bootstrapState` and `installFeatures` from `@wcstack/state/core`, then `installFeatures([...])` with the features you need (`features/temporal` = `$watch` / `$stream`, and `$scan`, which is deprecated in 3.5 and removed in 4.0 — write `$watch` or `$on` instead; `features/scopes` = `bind-component` / `mount=` / DCC, plus `recursion`, `ssr`, `formats`, `devtools`, `diagnostics`) **before** calling `bootstrapState()`. A declaration whose feature is missing throws `[wcs/feature-not-installed]`. Load the split files from jsDelivr's plain `/npm/…/dist/split/` paths or a bundler — **never `esm.run`**, which re-bundles a separate engine into each entry. The full-package `/auto` above needs none of this and stays the default; see `npm view @wcstack/state readme`.
54
+ `@wcstack/state` also ships **split entries** for a page that deliberately leaves features out. The full-package `/auto` above includes every feature, needs none of this, and stays the default. The features are `temporal` (`$watch` / `$stream`), `list-keys` (`$listKeys`), `scopes` (`bind-component`, `mount=`, DCC), `recursion` (`$recursion`), `ssr` (`enable-ssr`), `formats` (the locale formatting filters), `diagnostics` (full message sentences and development warnings) and `devtools`. A declaration whose feature is missing throws `[wcs/feature-not-installed]`. Two ways to load them:
55
+
56
+ - **The split auto entry**, one tag and no import map. `features="…"` on the document's root `<wcs-state>` names what must be there before any `<wcs-state>` starts (`scopes`) and the development aids (`diagnostics`, `devtools`); each state's `$features` names what it needs (`$features: ["temporal", "formats"]`), loaded before that state is built. An unknown name fails with `[wcs/feature-unknown]`.
57
+
58
+ ```html
59
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@wcstack/state@<version>/dist/split/auto.js"></script>
60
+ <wcs-state features="scopes diagnostics">…</wcs-state>
61
+ ```
62
+
63
+ - **A bundler or an import map**: import `bootstrapState` and `installFeatures` from `@wcstack/state/core`, then call `installFeatures([...])` with the features from `@wcstack/state/features/<name>` **before** `bootstrapState()`.
64
+
65
+ Load the split files from jsDelivr's plain, version-pinned `/npm/…/dist/split/` paths or a bundler — **never `esm.run`**, which re-bundles a separate engine into each entry and breaks the split auto entry's relative loads. See `npm view @wcstack/state readme`.
53
66
 
54
67
  ---
55
68
 
@@ -178,7 +191,7 @@ Multiple bindings are separated by `;`:
178
191
  | `attr.NAME` | Set an attribute (SVG-aware) |
179
192
  | `radio` | Radio group (two-way) |
180
193
  | `checkbox` | Checkbox group bound to an array (two-way) |
181
- | `onclick`, `on*` | Event handler |
194
+ | `onclick`, `on*` | Event handler (common bubbling events are delegated to the root — see [Event handlers](#event-handlers)) |
182
195
  | `.NAME` | Explicit property (3.1) — the same binding as `NAME:`, except it is never an event |
183
196
 
184
197
  **A property whose name starts with `on` needs the dotted form (3.1).** `online: x` is parsed as an event binding: it listens for a `"line"` event and **never writes the element's `online` property**. Put a dot in front to bind the property itself:
@@ -196,11 +209,12 @@ Multiple bindings are separated by `;`:
196
209
  | `#ro` | Read-only — disables the two-way write-back |
197
210
  | `#prevent` | `event.preventDefault()` on handlers |
198
211
  | `#stop` | `event.stopPropagation()` on handlers |
212
+ | `#direct` | On an `on*:` binding only: listen on the element itself instead of delegating to the root (see [Event handlers](#event-handlers)) |
199
213
  | `#onchange` | Use `change` instead of `input` for two-way binding |
200
214
  | `#init=<authority>` | Who wins the **initial** sync: `element` (the element's own value seeds the state — use with `<wcs-storage>` and monitors), `state`, `auto`, `none`. Without it the default comes from the member's `wcBindable` declaration |
201
215
  | `#sync=<timing>` | When an element-authority binding reads the element: `call` (default — when the binding attaches) or `connect` (once the element is in the document) |
202
216
 
203
- Combine after one `#`, comma separated: `value#ro,init=none: path`, `value#init=element,sync=connect: path`. A second `#` is a parse error.
217
+ Combine after one `#`, comma separated: `value#ro,init=none: path`, `value#init=element,sync=connect: path`, `onclick#direct,stop: save`. A second `#` is a parse error.
204
218
 
205
219
  ### Paths
206
220
 
@@ -213,13 +227,17 @@ Combine after one `#`, comma separated: `value#ro,init=none: path`, `value#init=
213
227
 
214
228
  ### Splitting state across files (`mount=`)
215
229
 
216
- A second `<wcs-state mount="cart" src="./cart.js">` grafts its module onto the root tree at `cart`; the page then reads `cart.total`. When the volume's own code needs a path from outside its subtree, inject it on that element (3.1):
230
+ A second `<wcs-state mount="cart" src="./cart.js">` grafts its module onto the root tree at `cart`; the page then reads `cart.total`, and calls the volume's methods by path too (`onclick: cart.add`). A volume holds data, getters, methods and its own `$connectedCallback` / `$disconnectedCallback`, with `this` rooted at its mount path — nothing else:
217
231
 
218
- ```html
219
- <wcs-state mount="cart" src="./cart.js" data-wcs="state.taxRate: settings.taxRate"></wcs-state>
220
- ```
232
+ - **It cannot read root paths.** There is no injection on the volume element (`data-wcs="state.taxRate: …"` is refused). A value that combines both belongs in a root getter, written with full paths:
233
+
234
+ ```javascript
235
+ // root state
236
+ get cartTotalWithTax() { return this["cart.subtotal"] * (1 + this["settings.taxRate"]); }
237
+ ```
221
238
 
222
- Inside `cart.js`, `this.taxRate` reads and writes the root's `settings.taxRate` (add `#ro` for read-only). One `state.<key>` per injection; the right side must be a static path — no wildcard, no `$`, no filter. The injected name exists only inside the volume: the page still reads `settings.taxRate`, and there is no `cart.taxRate`.
239
+ - **Declarations go on the root state**, with full paths (`$watch: { "cart.total"(cur) { … } }`). A volume that declares `$watch`, `$listKeys`, `$renderedCallback`, `$stream`, `$recursion`, `$behavior` or `$features`, or carries an injection, is not grafted (`console.error` says why). `$commandTokens`, `$eventTokens`, `$on` and `$errorCallback` in a volume are ignored with a `console.warn`.
240
+ - The root's `$renderedCallback(paths)` receives the whole tree's paths (`cart.items.*.name`); filter them by prefix.
223
241
 
224
242
  ### Structural directives
225
243
 
@@ -232,6 +250,11 @@ Always on a `<template>` element:
232
250
  <template data-wcs="else:"> ... </template>
233
251
  ```
234
252
 
253
+ - **`for:` takes no filters.** `for: items|take(2)` fails initialization with `[wcs/binding-syntax]` #121. Declare a getter that returns the list to show (`get firstTwo() { return this.items.slice(0, 2); }`) and loop over it (`for: firstTwo`).
254
+ - **`outerHTML:` / `outerText:` cannot be used inside `for:` / `if:` templates** (`[wcs/template-syntax]` #203): a row or branch keeps its nodes by position. Bind `innerHTML:` on a wrapper element instead.
255
+ - **Inside a row, a `*` means the row of the `for:` around it.** `{{ b.*.y }}` inside `for: a` throws `[wcs/wildcard-rank]` #1403; read another list's row in a getter with `$resolve(path, indexes)`.
256
+ - `{{ }}` and `$1` (the loop index) work inside templates. The elements 4.0 clones for rows and branches do not keep their `data-wcs` attribute, so do not select them with `[data-wcs…]` in CSS or tests — give them a class.
257
+
235
258
  ### Computed values
236
259
 
237
260
  Plain getters. Wildcard getters compute per row, and `$getAll` aggregates across rows:
@@ -258,6 +281,22 @@ removeItem(event, index) {
258
281
  }
259
282
  ```
260
283
 
284
+ **`on*:` handlers of `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 a Light DOM component's host) and runs the handlers of the elements the event passed, innermost first. Other event types, and two-way bindings (`value:`, `checked:`), listen on the element. With delegation:
285
+
286
+ - `event.currentTarget` is the root, not the element. Find the element with `event.target.closest("li")`, or use the loop index or a wildcard path.
287
+ - `#stop` stops the outer `on*:` handlers, but not listeners your own code added to an ancestor (they already ran). Your own `stopPropagation()` on an ancestor keeps the `on*:` handlers inside it from running. An element moved under another root (a dialog moved to `document.body`) loses its handler.
288
+
289
+ Where a handler needs the element-level behaviour, write `on*#direct:`. The listener is then on the element itself: `currentTarget` is the element, `#stop` stops your ancestor listeners, an ancestor's `stopPropagation()` does not affect it, and it follows the element anywhere. It combines with `#prevent` / `#stop` (`onclick#direct,stop: save`). An outer `#direct` handler runs before the delegated handlers inside it, so an inner `#stop` stops it only if the inner binding is `#direct` too.
290
+
291
+ ### Initialization and errors
292
+
293
+ - `<wcs-state>` loads its state, builds and applies every binding under its root, then runs `$connectedCallback`. By then the bindings exist, so `$connectedCallback` can emit commands to a defined element right away. A custom element whose class is not defined yet gets its property / `command.` / `eventToken.` bindings when it is defined: `await customElements.whenDefined("wcs-fetch")` before poking it.
294
+ - A state source that fails (a `src=` that throws, a `src="*.json"` that cannot be fetched, a `state="id"` with no `<script type="application/json" id="id">`) or a binding at page level that cannot be read (a syntax error, an unknown filter, #121, #203, #1403) fails initialization: `connectedCallbackPromise` rejects with the error, and the console reports it once — `[@wcstack/state] <wcs-state src="./state.js"> failed to initialize.` followed by the error. Run the lint (below) before you deploy.
295
+ - A `$connectedCallback` that throws or rejects is reported as `… $connectedCallback failed.`; `connectedCallbackPromise` rejects, but `getBindingsReady()` resolves — the page is bound.
296
+ - A binding that fails inside a `for:` / `if:` row, or that fails to apply later, goes to `$errorCallback(error, { path, bindingType, node })` when the state declares one, else to the console; the rest of the page keeps working.
297
+ - One root `<wcs-state>` per document or shadow root: a second one fails to initialize. Graft more state with `mount=`.
298
+ - On `/core` without the `diagnostics` feature, messages carry a code, a number and the values (`[@wcstack/state] [wcs/filter-unknown] #501 "uc"`) instead of the sentence: look the number up in [`docs/state-errors.md`](https://github.com/wcstack/wcstack/blob/main/docs/state-errors.md).
299
+
261
300
  ---
262
301
 
263
302
  ## What does not work
@@ -273,11 +312,27 @@ GOOD: <input data-wcs="onkeydown: add"> <!-- check event.key inside add()
273
312
  BAD: <div data-wcs="for: items"> ... </div>
274
313
  GOOD: <template data-wcs="for: items"> ... </template>
275
314
 
276
- <!-- `{{ }}` outside a template causes FOUC. -->
315
+ <!-- `{{ }}` outside a template causes FOUC (the raw text shows until state loads). -->
277
316
  BAD: <p>{{ count }}</p>
278
317
  GOOD: <p><span data-wcs="textContent: count"></span></p>
318
+ GOOD: <p><!--@@: count--></p> <!-- comment binding: no FOUC, no extra element -->
319
+
320
+ <!-- `for:` takes no filters. -->
321
+ BAD: <template data-wcs="for: items|take(2)">
322
+ GOOD: <template data-wcs="for: firstTwo"> <!-- get firstTwo() { return this.items.slice(0, 2); } -->
323
+
324
+ <!-- In a delegated handler, currentTarget is the root. -->
325
+ BAD: select(event) { event.currentTarget.classList.add("on"); } <!-- with onclick: select -->
326
+ GOOD: select(event) { event.target.closest("li").classList.add("on"); }
327
+ GOOD: <li data-wcs="onclick#direct: select"> <!-- currentTarget is the <li> -->
328
+
329
+ <!-- Markup inside an element whose content a binding sets is not bound. -->
330
+ BAD: <div data-wcs="textContent: title"><b data-wcs="textContent: count"></b></div>
331
+ GOOD: <div><span data-wcs="textContent: title"></span><b data-wcs="textContent: count"></b></div>
279
332
  ```
280
333
 
334
+ A comment binding is `<!--@@: expr-->` (or `<!--@@wcs-text: expr-->`): the same text binding as `{{ expr }}`, filters included, at page level and inside templates; the comment is replaced by a text node. It binds even with `$behavior.enableMustache: false`. It is not a binding inside `<textarea>` or `<title>`, which the browser parses as text. The same goes for the children of `<noscript>` and `<iframe>`.
335
+
281
336
  ```javascript
282
337
  // State must be REASSIGNED. In-place mutation is not tracked.
283
338
  BAD: this.items.push(x);
@@ -290,28 +345,82 @@ GOOD: this["user.name"] = "new";
290
345
  // Immutable array methods are the idiom.
291
346
  this.items = this.items.toSpliced(index, 1);
292
347
  this.items = this.items.map(t => t.id === id ? { ...t, done: true } : t);
348
+
349
+ // Writing a list element replaces the value AT THAT POSITION: the row (focus, typed text,
350
+ // <details> open state) stays. To move rows with their values, assign a new array —
351
+ // rows follow their objects.
352
+ const items = this.items.slice();
353
+ [items[0], items[1]] = [items[1], items[0]];
354
+ this.items = items;
355
+
356
+ // $scan, substr and the 3.2 old names are gone (see the table below).
357
+ BAD: $scan: { total: { from: "amount", initial: 0, fold: (sum, cur) => sum + cur } }
358
+ GOOD: total: 0, $watch: { amount(cur) { this.total = this.total + cur; } }
359
+
360
+ // bootstrapState() takes markup spelling only; behaviour is declared in the state.
361
+ BAD: bootstrapState({ enableMustache: false }); // throws
362
+ GOOD: export default { $behavior: { enableMustache: false }, /* … */ };
363
+ ```
364
+
365
+ ---
366
+
367
+ ## Configuration
368
+
369
+ A page that loads `/auto` calls nothing. With the module entries (`@wcstack/state`, `@wcstack/state/core`), `bootstrapState(config)` takes only how the page **spells** the markup — `bindAttributeName`, `tagNames.state` / `tagNames.ssr`, `commentForPrefix` / `commentIfPrefix` / `commentElseIfPrefix` / `commentElsePrefix` — plus `locale` and `enableContractAnalyzer`. The default `locale` is `<html lang>`, read when the module is evaluated: write it in the markup.
370
+
371
+ How a state tree behaves, and which features it needs, is declared in the state:
372
+
373
+ ```javascript
374
+ export default {
375
+ $behavior: { enableMustache: false }, // enableMustache, sameValueGuard, enableDirectionalInitialSync — booleans, all true by default
376
+ $features: ["temporal"], // the features this state needs (checked; loaded by the split auto entry)
377
+ count: 0,
378
+ };
293
379
  ```
294
380
 
381
+ - `$behavior` covers the tree of the `<wcs-state>` that declares it, its volumes included. Every root state that needs it declares it — the page's root, a root in a shadow root, a mounted component, a DCC; nothing is inherited, and a volume cannot declare it. An unknown key or a non-boolean throws (#44); a re-set (`setInitialState()`) cannot change it (#45). It works in JSON states and on `/auto` pages too.
382
+ - **Every package's `bootstrapXxx(config)`** — `bootstrapState`, `bootstrapRouter`, `bootstrapFetch`, … — throws on an option it does not have, on a value whose type differs from the option's default, and on a `tagNames` key it does not define, and then applies nothing. An `undefined` value is skipped.
383
+
295
384
  ---
296
385
 
297
- ## Names that changed in 3.2
386
+ ## User content and security
298
387
 
299
- Older names appear all over the training data. They still work through 3.x and are removed in 4.0, but `@wcstack/lint` and the VS Code extension flag each one (`wcs/name-alias`, info severity — it does **not** change the exit code, so it is easy to miss). Write the canonical name.
388
+ `@wcstack/state` does not sanitize values. Values are safe where they are text, and only there:
300
389
 
301
- | Canonical | Old name |
390
+ - **Bind user text with `textContent:`, `{{ }}` or a comment binding.** A value a binding writes is never read as a binding: `{{ }}` or `data-wcs` inside a value stays literal.
391
+ - **`innerHTML:` / `html:` only with HTML you sanitized** — install a sanitizing Trusted Types policy (`docs/csp.md` §7).
392
+ - **`href:`, `src:` and `attr.href:` accept `javascript:` URLs, and `attr.on*:` accepts handler strings.** state rejects neither: validate the value, and use a CSP whose `script-src` has no `'unsafe-inline'`.
393
+ - **Never put user content into the page's markup** — a server template that is not wcstack SSR, or HTML your code inserts. The page walk reads every `{{ … }}`, `data-wcs` attribute and comment binding (`<!--@@: x-->`) in the markup as a binding (client-side template injection), and `$behavior.enableMustache: false` does not stop comment bindings. 4.0 has no attribute that excludes a subtree from the walk (there is no `data-wcs-ignore`). Deliver such content as state data — a `<script type="application/json">` read with `state="id"`, a `src=` module, `fetch` — and bind it with `textContent:`.
394
+
395
+ ---
396
+
397
+ ## Names 4.0 removed
398
+
399
+ Older names appear all over the training data. 4.0 rejects them: write the name in the left column.
400
+
401
+ | Write | Removed in 4.0 |
302
402
  |---|---|
303
403
  | `upper` · `lower` · `capitalize` | `uc` · `lc` · `cap` |
304
404
  | `add` · `sub` | `inc` · `dec` |
305
405
  | `toFixed` · `padStart` | `fix` · `pad` |
306
406
  | `repeat` · `reverse` | `rep` · `rev` |
307
407
  | `nullIfEmpty` | `null` |
408
+ | `slice(start, start + length)` (an end index, not a length) | `substr(start, length)` |
308
409
  | `$stream` (async producer → state) | `$streams` |
309
410
  | `$renderedCallback` (hook) | `$updatedCallback` |
411
+ | `$watch` (fold a path's changes) · `$on` (fold event tokens) into an ordinary key | `$scan` |
310
412
  | `this.$dependOn(path)` · `this.$untracked(fn)` | `$trackDependency` · `$untrackDependency` |
311
413
 
312
- Declaring both spellings of a state key fails with `[wcs/declaration-alias]`. `$renderedCallback` reports the **bindings that were applied**, not every state change — use `$watch` for those.
414
+ An old filter name or `substr` fails initialization with `[wcs/filter-unknown]` (the full entries name the replacement); `$streams`, `$updatedCallback` and `$scan` throw when the state loads; `$trackDependency` / `$untrackDependency` throw when read. The lint reports the declarations and API names as errors, and the filters as `wcs/filter-unknown` warnings (a page may register filters of its own) — which still throw at run time. `$renderedCallback` reports the **bindings that were applied**, not every state change — use `$watch` for those.
415
+
416
+ ---
417
+
418
+ ## Known limitations
419
+
420
+ - **One object reachable from two rows** — the same object at two positions of a list, or in two lists (a getter returning a filtered copy of `todos`, rendered with `for:`, while a row writes into it): a write below one row does not reach the other row's bindings and row getters. Do not write below such a row; replace the object in the top-level list instead, as the app above does (`toggle()` reassigns `todos` with a new object for the changed item, and the checkbox is `checked#ro:`).
421
+ - **Numeric keys under a plain object** (`sales.2024.total`, `usersById.42.name`): markup renders them, but a script read gives `undefined` and a write throws. Read `this.sales[2024].total`, write by assigning a new object to the top-level key, or use keys that are not numbers.
313
422
 
314
- `@wcstack/state` 3.5 also prints each old name once per page at runtime (`[wcs/v4-migration]`), together with two more forms that 4.0 removes: `$scan` (write `$watch` for state paths, `$on` for event tokens) and the `substr(start, length)` filter (write `slice(start, start + length)`).
423
+ Details, and the other known limitations (enumerating the state after a re-set, an endless `$watch` → `$stream` loop, elements removed and put back, comments between chained `if:` templates under SSR): the migration guide, [§5](https://github.com/wcstack/wcstack/blob/main/docs/migration-v4.md#5-known-limitations).
315
424
 
316
425
  ---
317
426
 
@@ -325,7 +434,7 @@ npx @wcstack/lint index.html
325
434
  npx @wcstack/lint --errors-only index.html
326
435
  ```
327
436
 
328
- Exit code `0` means no error-severity finding, `1` means at least one, `2` means a usage or read failure. **Warnings and info diagnostics do not change the exit code** — `0` is not the same as "no findings", so read the output too (or pass `--strict`, which makes warnings exit `1` as well). Diagnostics carry stable `wcs/*` codes and `source:line:col` ranges.
437
+ Exit code `0` means no error-severity finding, `1` means at least one, `2` means a usage or read failure. **Warnings and info diagnostics do not change the exit code** — `0` is not the same as "no findings", so read the output too (or pass `--strict`, which makes warnings exit `1` as well). Two warnings throw at run time in 4.0 — `wcs/filter-unknown` and `wcs/wildcard-rank` — so treat them as errors. Diagnostics carry stable `wcs/*` codes (the same codes the runtime prints) and `source:line:col` ranges.
329
438
 
330
439
  ---
331
440