tutuca 0.9.97 → 0.9.98

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tutuca",
3
- "version": "0.9.97",
3
+ "version": "0.9.98",
4
4
  "type": "module",
5
5
  "description": "Zero-dependency SPA framework with immutable state and virtual DOM",
6
6
  "main": "./dist/tutuca.js",
@@ -99,9 +99,9 @@
99
99
  },
100
100
  "homepage": "https://github.com/marianoguerra/tutuca#readme",
101
101
  "devDependencies": {
102
- "@biomejs/biome": "^2.4.12",
102
+ "@biomejs/biome": "^2.5.1",
103
103
  "chai": "^6.2.2",
104
- "fast-check": "^4.7.0",
104
+ "fast-check": "^4.8.0",
105
105
  "htmlparser2": "^12.0.0"
106
106
  }
107
107
  }
@@ -124,11 +124,20 @@ in the producer's own view update in lock-step.
124
124
  Tutuca's special operations (`render`, `render-it`, `render-each`, `text`,
125
125
  `show`, `hide`, `slot`) live on the `<x>` tag. That works almost
126
126
  everywhere, but the browser's HTML parser refuses to keep `<x>` (or any
127
- unknown tag) as a child of certain elements: `<select>` only allows
128
- `<option>` / `<optgroup>`, `<table>` only allows `<thead>` / `<tbody>` /
129
- `<tr>`, `<tr>` only allows `<th>` / `<td>`, etc. Drop `<x render-each>`
127
+ unknown tag) as a child of certain elements. Drop `<x render-each>`
130
128
  inside one of those and the parser silently strips it.
131
129
 
130
+ The parser strips `<x>` only inside the **table family** and **`<select>`**.
131
+ Use pseudo-`@x` when the parent is one of:
132
+
133
+ `table`, `thead`, `tbody`, `tfoot`, `tr`, `colgroup`, `select`, `optgroup`.
134
+
135
+ Everywhere else `<x>` is kept and needs no workaround — including `ul`, `ol`,
136
+ `li`, `dl`, `dt`, `dd`, `details`, `summary`, `caption`, `td`, `th`. So
137
+ `<ul><x render-each=".items">…</x></ul>` is fine. (When in doubt, the rule of
138
+ thumb is: any element whose HTML content model only permits *specific* child
139
+ tags — table sections and `<select>` — strips `<x>`.)
140
+
132
141
  The escape hatch: prefix the **first** attribute on a *legal* tag with
133
142
  `@x`. Tutuca treats that tag as if it were `<x>` and reads the next
134
143
  attribute as the special op.
@@ -151,8 +160,8 @@ Notes:
151
160
  `render`, `text`, `show`, ...) is the second.
152
161
  - The host tag (here `<option>`) is otherwise ignored — only the special
153
162
  op runs. Tutuca produces the rendered children directly.
154
- - Same trick works inside `<tr>`, `<table>`, `<colgroup>`, `<dl>`,
155
- `<details>`, or anywhere else the parser would discard `<x>`.
163
+ - Same trick works inside any of the stripping parents listed above
164
+ (`<table>`/`<tr>`/`<colgroup>`/`<select>`/…).
156
165
 
157
166
  ## Registering a custom seq type
158
167
 
@@ -248,8 +248,9 @@ The one exception is **boolean predicates** in conditional slots
248
248
  a value, written predicate-first like a handler call —
249
249
  `empty?`, `truthy?`, `falsy?`, `null?`, `equals?`. E.g.
250
250
  `@hide="empty? .items"`, `@show="truthy? .query"`. A conditional slot
251
- still accepts a plain field (`@show=".isOpen"`) or no-arg method
252
- (`@show="$canSubmit"`) name too.
251
+ otherwise accepts the same value forms as `@text` a plain field
252
+ (`@show=".isOpen"`), a no-arg method (`@show="$canSubmit"`), or a loop/scope
253
+ `@binding` (`@show="@isSelected"`, `@hide="@hasDesc"`) — read as a boolean.
253
254
 
254
255
  `equals?` takes two args and is the idiomatic way to show/hide by name,
255
256
  e.g. `@show="equals? .view 'detail'"`. Predicate args (and handler
@@ -517,13 +518,21 @@ other inline content, or a loop binding). Both take the same value forms
517
518
  ```html
518
519
  <input :value=".str" @on.input="$setStr value" />
519
520
  <a :href=".url" :title="$'Hi {.name}'">link</a> <!-- string template -->
520
- <button class="btn" :class="$'btn {.color}'">x</button>
521
+ <button :class="$'btn {.color}'">x</button>
521
522
  ```
522
523
 
523
524
  Plain attrs are static. `:attr="..."` is a dynamic expression. Boolean
524
525
  HTML attributes (`disabled`, `checked`, `hidden`, …) are auto-recognized;
525
526
  pass a boolean field.
526
527
 
528
+ A static `class="…"` and a dynamic `:class`/`@if.class` **cannot coexist on the
529
+ same element** — setting one attribute two ways is a lint error
530
+ (`DUPLICATE_ATTR_DEFINITION`), and at runtime the dynamic value wins and the
531
+ static class is dropped. Fold any structural classes into the bound expression,
532
+ e.g. `:class="$'btn {.color}'"` (note `btn` is part of the template, not a
533
+ separate `class="btn"`). The same applies to other attributes — see the
534
+ duplicate-attribute note below.
535
+
527
536
  The HTML parser lowercases attribute names before Tutuca sees them, so
528
537
  `:mapId` arrives as `:mapid` and `<x:Card>` becomes `<x:card>`. Three
529
538
  consequences:
@@ -549,6 +558,24 @@ via `is="..."` (e.g. `<button is="x-fancy">`); `is` is applied when the
549
558
  element is created, so it must be a static attribute — setting it later
550
559
  does not upgrade the element.
551
560
 
561
+ ### When nothing renders (or renders unstyled)
562
+
563
+ A few mistakes fail quietly — no error, just a blank or unstyled result, which
564
+ is the slowest kind to debug. **Run `tutuca lint <module>` first**: it catches
565
+ several of these. The usual suspects:
566
+
567
+ - **Unparseable attribute value** → the attribute is silently dropped. A bare
568
+ multi-word value isn't a string — quote it (`:label="'two words'"`) or make it
569
+ a template (`:label="$'{.a} {.b}'"`). Lint flags this as `BAD_VALUE`.
570
+ - **camelCase attribute on a custom element** → setter no-op (see the lowercasing
571
+ note above). Use kebab-case attributes. Not lintable — the HTML parser
572
+ lowercases the name before either Tutuca or the linter sees it.
573
+ - **Forgotten margaui `_palette`/decoy view** → classes assembled in methods or
574
+ interpolations render unstyled. See [margaui.md](./margaui.md). Not lintable.
575
+ - **A whitespace-only `html\`\``** → blank render. A *leading* newline before the
576
+ root element is fine (the parser trims it); a template with no element at all
577
+ is not.
578
+
552
579
  ## Event Handling
553
580
 
554
581
  ```html
@@ -714,8 +741,8 @@ share the handler-name resolution rules below.
714
741
  alter: {
715
742
  filterItem(_key, item, iterData) { return item.includes(iterData.q); },
716
743
  enrichItem(binds, _key, item, iterData) { binds.count = item.length; },
717
- // `@loop-with` returns { iterData?, start?, end? } — all optional.
718
- getIterData(seq) {
744
+ // `@loop-with` is `(seq, ctx)` and returns { iterData?, start?, end?, keys? }.
745
+ getIterData(seq, ctx) {
719
746
  const start = this.page * this.pageSize;
720
747
  return { iterData: { q: this.query.toLowerCase() }, start, end: start + this.pageSize };
721
748
  },
@@ -724,7 +751,7 @@ alter: {
724
751
 
725
752
  #### `@loop-with` return shape — `iterData` + slicing
726
753
 
727
- A `@loop-with` handler returns an object with up to three optional keys:
754
+ A `@loop-with` handler returns an object with up to four optional keys:
728
755
 
729
756
  - **`iterData`** — the shared per-loop value handed to `@when` /
730
757
  `@enrich-with`. Defaults to `{ seq }` when omitted.
@@ -733,11 +760,43 @@ A `@loop-with` handler returns an object with up to three optional keys:
733
760
  from the end (`end: -3` drops the last 3), `undefined` means the
734
761
  natural bound. Use this to **paginate** — skip a prefix and/or suffix
735
762
  without iterating or rendering it.
763
+ - **`keys`** — an explicit, ordered array of **original keys** to visit,
764
+ for **filter-then-paginate**. The handler filters/sorts/slices the full
765
+ sequence itself and returns the current page's slice of original keys;
766
+ the renderer visits exactly those (`seq.get(key)`), in order. Takes
767
+ precedence over `start`/`end` when both are present.
736
768
 
737
769
  Slicing is positional but **preserves each item's original key**: a List
738
770
  sliced to `start: 2` still binds `@key` to `2, 3, …`, so events, drag,
739
- and two-way binding keep their identity. `@when` then filters *within*
740
- the window, so a page may yield fewer than `end - start` items.
771
+ and two-way binding keep their identity. With `start`/`end`, `@when` then
772
+ filters *within* the window, so a page may yield fewer than `end - start`
773
+ items — to filter *before* paging (so the page count reflects the filtered
774
+ total), return `keys` instead. `keys` are original keys, so identity is
775
+ preserved there too: editing or deleting a row on page 2 of a filtered view
776
+ hits the right item. A `keys` return is **authoritative** — the renderer
777
+ visits exactly those keys and does **not** re-apply `@when` (the handler has
778
+ already decided what renders).
779
+
780
+ #### `@loop-with` handler context — `(seq, ctx)`
781
+
782
+ The handler's second argument is `ctx = { lookup, filter }` (an object so it
783
+ can grow):
784
+
785
+ - **`ctx.lookup(name)`** — reads a scope `@`-binding, e.g. one published by an
786
+ ancestor scope `@enrich-with`. Lets the handler **reuse a value the enrich
787
+ already computed** instead of recomputing it.
788
+ - **`ctx.filter(key, value, iterData)`** — wraps the declared `@when` predicate
789
+ (always callable; a no-op that returns `true` when there is no `@when`). Lets
790
+ the handler apply the *declared* filter while building its `keys` slice,
791
+ rather than re-implementing the match test.
792
+
793
+ This is the **filter-then-paginate, minimal-work** pattern: a scope
794
+ `@enrich-with` on an ancestor does **one** counting scan and publishes the
795
+ clamped page + pager labels (which the page controls, sitting outside the loop,
796
+ read as `@`-bindings); the `@loop-with` handler then reads the clamped page via
797
+ `ctx.lookup`, reuses the predicate via `ctx.filter`, and collects only the
798
+ current page's keys — early-exiting once the page is full. See
799
+ [patterns/filter-and-paginate.md](patterns/filter-and-paginate.md).
741
800
 
742
801
  ### Lifecycle of `@each`
743
802
 
@@ -745,14 +804,18 @@ For each render of an element with `@each=".items"`:
745
804
 
746
805
  1. **Resolve sequence** — evaluate `.items`. Lists, IMaps, OMaps, ISets,
747
806
  and any class declaring a `SEQ_INFO` walker are recognized.
748
- 2. **`@loop-with`** (once per render) — `getIterData.call(this, seq)` is
749
- called with the full sequence; its `iterData` becomes the shared
750
- per-loop value and its `start`/`end` slice the iteration. Skipped if
751
- no `@loop-with`; then `iterData` is `{ seq }` and the whole sequence
752
- is iterated.
753
- 3. For each `(key, value)` pair in the sliced sequence:
807
+ 2. **`@loop-with`** (once per render) — `getIterData.call(this, seq, ctx)`
808
+ is called with the full sequence and the `{ lookup, filter }` context;
809
+ its `iterData` becomes the shared per-loop value and its `start`/`end`
810
+ slice the iteration. Skipped if no `@loop-with`; then `iterData` is
811
+ `{ seq }` and the whole sequence is iterated. If it returns `keys`,
812
+ those exact keys are visited in order (filter-then-paginate) and
813
+ `start`/`end` are ignored.
814
+ 3. For each `(key, value)` pair in the sliced sequence (or each `key` in
815
+ `keys`):
754
816
  1. **`@when`** — `filterItem.call(this, key, value, iterData)`; if it
755
- returns `false`, the item is skipped.
817
+ returns `false`, the item is skipped. **Not applied** when the
818
+ handler returned `keys` (those are authoritative).
756
819
  2. **`@enrich-with`** — `enrichItem.call(this, binds, key, value, iterData)`.
757
820
  `binds` is a **mutable object** seeded with `{ key, value }`;
758
821
  mutating it (`binds.count = ...`) creates `@`-prefixed bindings
@@ -95,24 +95,36 @@ available (`npx tutuca install-skill --margaui-skill`) — it lists the
95
95
  available components and their canonical class strings, which is what the
96
96
  `compile` step expects.
97
97
 
98
- ## Pitfall: @if.class is invisible to the scanner
98
+ ## Pitfall: assembled class names are invisible to the scanner
99
99
 
100
- Classes inside `@then` / `@else` (e.g.
101
- `@if.class=".active" @then="'btn-success'" @else="'btn-ghost'"`) are not
102
- literals in `class=` / `:class=`, so `compileClassesToStyleText` skips
103
- them and the margaui CSS for those classes is never emitted — the
104
- conditional class renders unstyled. Workaround: add a hidden "decoy" view
105
- on the component that lists every conditional class as a real literal, so
106
- the walker picks them up:
100
+ The scanner only reads **constant** class literals out of parsed templates. It
101
+ cannot see a class name that is assembled rather than written out verbatim, so
102
+ the margaui CSS for that class is never emitted and it renders unstyled. Two
103
+ cases:
104
+
105
+ - **Interpolated templates** `:class="$'bg-{.color}'"` contributes only the
106
+ constant prefix `bg-`, never `bg-red` / `bg-blue`. Same for any `${…}` segment.
107
+ - **Classes built in a method** — anything a method returns (e.g. a `headerClass()`
108
+ that builds `` `progress-${this.color}` ``) is never scanned at all; the walker
109
+ only reads view templates, not JS bodies.
110
+
111
+ (Literal `@then` / `@else` strings on `@if.class` — e.g.
112
+ `@if.class=".active" @then="'btn-success'" @else="'btn-ghost'"` — **are** now
113
+ collected, so those don't need the workaround.)
114
+
115
+ Workaround: add a hidden "decoy"/palette view on the component that lists every
116
+ possible assembled class as a real literal, so the walker picks them up:
107
117
 
108
118
  ```js
109
- _margauiClasses: html`<p class="btn-success btn-ghost on off"></p>`,
119
+ // enumerate color × utility so each full class name appears verbatim
120
+ _margauiClasses: html`<p class="bg-red bg-blue progress-red progress-blue"></p>`,
110
121
  ```
111
122
 
112
- The view does not need to be rendered anywhere — registration is enough
113
- for the template walker to find it. (This is the same rule
114
- [component-design.md](./component-design.md) gives for runtime-assembled
115
- margaui classes.)
123
+ The view does not need to be rendered anywhere — registration is enough for the
124
+ template walker to find it. (This is the same rule
125
+ [component-design.md](./component-design.md) gives for runtime-assembled margaui
126
+ classes.) The cost is that the palette and the methods can drift apart with no
127
+ check catching it; keep them adjacent and update both together.
116
128
 
117
129
  ## See also
118
130
 
@@ -14,6 +14,7 @@ task.
14
14
  - [Filter a list](filter-a-list.md) — keep only matching items with `@when`.
15
15
  - [Enrich each item](enrich-each-item.md) — expose derived per-item values as `@`-bindings.
16
16
  - [Paginate a list](paginate-a-list.md) — slice the iteration with `@loop-with` `start`/`end`.
17
+ - [Filter and paginate a list](filter-and-paginate.md) — do both with `@loop-with` `keys` (filter-then-slice, identity preserved).
17
18
 
18
19
  ## Conditional content & attributes
19
20
 
@@ -20,4 +20,6 @@ alter: {
20
20
  `@when` names an `alter` handler called per item as `(key, value, iterData)`;
21
21
  return `false` to skip. It filters *after* any `@loop-with` slice, so a page
22
22
  can yield fewer than its window. Filtering reads other fields off `this`
23
- directly (`this.query`) — there are no paths in the template.
23
+ directly (`this.query`) — there are no paths in the template. To filter
24
+ *before* paging, return `keys` from `@loop-with` instead — see
25
+ [filter-and-paginate.md](filter-and-paginate.md).
@@ -0,0 +1,116 @@
1
+ # Filter and paginate a list
2
+
3
+ **Problem:** show one page of the items that match a query — filtering
4
+ *before* paging, so page counts reflect the filtered total and a row's
5
+ identity survives editing or deleting across pages — without scanning the
6
+ list more than necessary.
7
+
8
+ ```html
9
+ <section @enrich-with="pageInfo"> <!-- COUNT pass: runs once -->
10
+ <input :value=".query" @on.input="search value" />
11
+ <li @each=".items" @when="onlyMatches" @loop-with="page"> <!-- COLLECT pass -->
12
+ <span @text="@key"></span> <x render-it></x>
13
+ <button @on.click="$removeInItemsAt @key">✕</button>
14
+ </li>
15
+ <button :disabled="@isFirst" @on.click="prev">‹</button>
16
+ <button @text="@pageLabel"></button>
17
+ <button :disabled="@isLast" @on.click="next">›</button>
18
+ </section>
19
+ ```
20
+
21
+ ```js
22
+ methods: { matchCount() { /* one scan: how many match this.query */ } },
23
+ alter: {
24
+ onlyMatches(_key, p) { return matches(p, this.query); }, // the predicate
25
+ pageInfo() { // scope enrich: the COUNT scan
26
+ const total = this.matchCount();
27
+ const { pageCount, currentPage } = clamp(this.page, total, this.pageSize);
28
+ return { currentPage, isFirst: currentPage <= 0, isLast: currentPage >= pageCount - 1,
29
+ pageLabel: `Page ${currentPage + 1} of ${pageCount} · ${total}` };
30
+ },
31
+ page(seq, { lookup, filter }) { // @loop-with: the COLLECT scan
32
+ const start = lookup("currentPage") * this.pageSize, end = start + this.pageSize;
33
+ const keys = [];
34
+ let m = 0;
35
+ for (let i = 0; i < seq.size && m < end; i++) // early-exit: stops at page end
36
+ if (filter(i, seq.get(i))) { if (m >= start) keys.push(i); m++; }
37
+ return { keys };
38
+ },
39
+ },
40
+ ```
41
+
42
+ Returning **`keys`** (ordered *original* keys) is what makes this work: the
43
+ renderer visits exactly those and does **not** re-apply `@when`, and because
44
+ `@key` stays the original index, deleting row `@key` on page 2 of a filtered
45
+ view hits the right item. The page controls live *outside* the loop, so they
46
+ can't read its `iterData`; instead a scope `@enrich-with` does the one counting
47
+ scan and publishes the clamped page + labels as `@`-bindings. The `@loop-with`
48
+ handler's `ctx` lets it avoid repeating that work: `ctx.lookup` reads the
49
+ clamped page the enrich already computed, and `ctx.filter` reuses the declared
50
+ `@when` predicate — so the collect pass scans just far enough to fill the page.
51
+ Reset `page` to 0 when the query changes.
52
+
53
+ ## Three ways to wire it
54
+
55
+ The block above is the **shared** strategy. There are three, trading simplicity
56
+ for scans-per-render (all return `keys`, so all keep identity):
57
+
58
+ **1. Naive — two independent scans.** The loop scans + slices the whole list
59
+ itself; a separate `@enrich-with` scans again for the labels. Simplest, nothing
60
+ shared:
61
+
62
+ ```js
63
+ naiveTablePage(seq, { filter }) { // builds the WHOLE matching list…
64
+ const all = [];
65
+ for (let i = 0; i < seq.size; i++) if (filter(i, seq.get(i))) all.push(i);
66
+ const start = clamp(this.page, all.length, this.pageSize).currentPage * this.pageSize;
67
+ return { keys: all.slice(start, start + this.pageSize) }; // …just to slice it
68
+ },
69
+ ```
70
+
71
+ **2. Shared — one count + one partial collect** (the block above). The enrich
72
+ counts and publishes `@currentPage`; the loop reuses it via `lookup` and stops
73
+ once the page is full.
74
+
75
+ **3. Coupled — one scan.** The enrich does *everything*, including the page keys,
76
+ and stashes them in a binding only the loop reads. Fastest, but the two handlers
77
+ are welded together — name them so it shows:
78
+
79
+ ```js
80
+ enrichBuildsKeysForTheLoopBelow() { // the only scan: count + labels + keys
81
+ const all = []; /* …collect matching indices… */
82
+ const { pageCount, currentPage } = clamp(this.page, all.length, this.pageSize);
83
+ const start = currentPage * this.pageSize;
84
+ return { __keys__: all.slice(start, start + this.pageSize), /* …labels… */ };
85
+ },
86
+ loopJustForwardsTheEnrichsKeys(_seq, { lookup }) { // useless without the enrich
87
+ return { keys: lookup("__keys__") };
88
+ },
89
+ ```
90
+
91
+ ## Testing each strategy
92
+
93
+ `collectIterBindings(Comp, instance, seq, opts)` drives a loop exactly like the
94
+ renderer and returns the `{ key, value }` binds it would render. Map the
95
+ template's directives to `opts` — `when` → `@when`, `loopWith` → `@loop-with`,
96
+ `scopeEnrich` → the ancestor scope `@enrich-with` whose result the loop reads via
97
+ `ctx.lookup`:
98
+
99
+ ```js
100
+ import { collectIterBindings } from "tutuca";
101
+ const keys = (Comp, c, opts) => collectIterBindings(Comp, c, c.items, opts).map((b) => b.key);
102
+
103
+ // each strategy wires the directives differently, yet renders the same page:
104
+ keys(Naive, c, { when: "onlyMatches", loopWith: "naiveTablePage" });
105
+ keys(Shared, c, { when: "onlyMatches", loopWith: "page", scopeEnrich: "pageInfo" });
106
+ keys(Coupled, c, { loopWith: "loopJustForwardsTheEnrichsKeys",
107
+ scopeEnrich: "enrichBuildsKeysForTheLoopBelow" });
108
+ ```
109
+
110
+ A `keys` return is honored as-is (no `@when` re-applied), and `scopeEnrich` runs
111
+ the named scope handler so a `lookup("currentPage")` / `lookup("__keys__")`
112
+ resolves in the test. `collectIterBindings` lives in the **dev build**; both the
113
+ playground's Test tab (via its import map) and `tutuca test` (which redirects the
114
+ `"tutuca"` import to the dev build under Node) resolve it to the real
115
+ implementation. See [filter-a-list.md](filter-a-list.md) and
116
+ [paginate-a-list.md](paginate-a-list.md) for each half on its own.
@@ -24,4 +24,6 @@ slice with `Array.prototype.slice` semantics (`end` exclusive, negatives count
24
24
  from the end). Slicing is positional but **preserves each item's original
25
25
  key** — `@key` is the index in the full list, so events and two-way binding
26
26
  keep their identity across pages. `iterData` is the shared per-loop value
27
- handed to `@when` / `@enrich-with`.
27
+ handed to `@when` / `@enrich-with`. To paginate a *filtered* list, return
28
+ `keys` instead of `start`/`end` — see
29
+ [filter-and-paginate.md](filter-and-paginate.md).
@@ -27,7 +27,7 @@ same file is a valid target for `tutuca lint` / `test` / `render` too.
27
27
 
28
28
  | Export | Returns | Used for |
29
29
  | ------ | ------- | -------- |
30
- | `getComponents()` | `[Comp, ...]` | stories — return **every** component the module defines, children and helpers included |
30
+ | `getComponents()` | `[Comp, ...]` | stories — return **every** component the module defines, children and helpers included. Components dedup by identity, so re-listing a leaf that another module also lists is safe (a composition module can re-list every leaf it uses). |
31
31
  | `getExamples()` | one section, or an array of sections | the catalog cards |
32
32
  | `getTests({ describe, test, expect })` | tests | the pre-serve test run (optional) |
33
33
  | `getMacros()` | `{ name: macro }` | macros referenced in views (optional) |
@@ -37,7 +37,12 @@ same file is a valid target for `tutuca lint` / `test` / `render` too.
37
37
  ## Authoring stories (`getExamples`)
38
38
 
39
39
  Return one section, or an array of sections to group examples under multiple
40
- headings. A section is `{ title, description?, items: [...] }`:
40
+ headings. A section is `{ title, description?, items: [...] }`. An array of one
41
+ section behaves exactly like returning that section directly — both go through
42
+ the same `Section.fromData`, which **throws** on a malformed section (missing
43
+ `title`, not an object) rather than rendering a placeholder title. (If you saw
44
+ broken titles from a one-element array in an older build, that predates array
45
+ support — it has shipped since well before 0.9.88.)
41
46
 
42
47
  ```js
43
48
  import { component, html } from "tutuca";
@@ -114,10 +114,21 @@ export function getTests({ describe, test, expect, drive }) {
114
114
  - `drive(value, phase, opts?)` builds a transactor over `value`, dispatches the
115
115
  phase's actions at the root, awaits the whole cascade (including async
116
116
  requests), and returns the **settled** instance.
117
+ - `drive` **always originates at the root** — there is no `at:`/path option. To
118
+ exercise a handler on a nested child, call it directly with `.call(child, …)`.
117
119
  - `phase` is the same shape as an example's `on.init`
118
120
  (`{ send, bubble, request, input, do }`; see
119
121
  [storybook.md](./storybook.md#lifecycle-hooks-on)). `args` may be a function
120
122
  `(self) => [...]`.
123
+ - A `bubble` action is a **no-op under `drive`**: bubbles travel child→parent, and
124
+ at the root there is no ancestor to receive it (the root's own `bubble` handler
125
+ is skipped too). To test a `bubble` handler, call it directly. (`drive` warns
126
+ when a phase contains a `bubble`.)
127
+ - These are *action kinds*, not methods. `$`-prefixed **methods** (auto setters/
128
+ togglers, `$foo`) are not an action kind — `on`/`drive` can only reach state
129
+ through `input`/`receive`/`response` handlers. To put a component into a method-
130
+ driven state for a test, call the method directly or route it through an `input`
131
+ handler.
121
132
  - `request` actions resolve against the module's `getRequestHandlers()`.
122
133
  - `opts.onMessage(message, before, after)` observes every committed transaction —
123
134
  `message` is `{ kind, name, args, path }`, `before`/`after` are the root values
@@ -1744,37 +1744,47 @@ class Renderer {
1744
1744
  renderEach(stack, iterInfo, node, viewName) {
1745
1745
  const { seq, filter, loopWith } = iterInfo.eval(stack);
1746
1746
  const r = [];
1747
- const { iterData, start, end } = unpackLoopResult(loopWith.call(stack.it, seq), seq);
1748
- getSeqInfo(seq)(seq, (key, value, attrName) => {
1749
- if (filter.call(stack.it, key, value, iterData)) {
1750
- const dom = this.renderIt(stack.enter(value, { key }, true), node, key, viewName);
1751
- this.pushEachEntry(r, node.nodeId, attrName, key, dom);
1752
- }
1753
- }, start, end);
1747
+ const { iterData, start, end, keys } = unpackLoopResult(loopWith.call(stack.it, seq, makeLoopCtx(stack, filter)), seq);
1748
+ const renderOne = (key, value, attrName) => {
1749
+ const dom = this.renderIt(stack.enter(value, { key }, true), node, key, viewName);
1750
+ this.pushEachEntry(r, node.nodeId, attrName, key, dom);
1751
+ };
1752
+ if (keys)
1753
+ imKeysIter(seq, renderOne, keys);
1754
+ else
1755
+ getSeqInfo(seq)(seq, (key, value, attrName) => {
1756
+ if (filter.call(stack.it, key, value, iterData))
1757
+ renderOne(key, value, attrName);
1758
+ }, start, end);
1754
1759
  return r;
1755
1760
  }
1756
1761
  renderEachWhen(stack, iterInfo, view, nid) {
1757
1762
  const { seq, filter, loopWith, enricher } = iterInfo.eval(stack);
1758
1763
  const r = [];
1759
1764
  const it = stack.it;
1760
- const { iterData, start, end } = unpackLoopResult(loopWith.call(it, seq), seq);
1761
- getSeqInfo(seq)(seq, (key, value, attrName) => {
1762
- if (filter.call(it, key, value, iterData)) {
1763
- const cachePath = enricher ? [view, it, value] : [view, value];
1764
- const binds = { key, value };
1765
- const cacheKey = `${nid}-${key}`;
1766
- if (enricher)
1767
- enricher.call(it, binds, key, value, iterData);
1768
- const cachedNode = this.cache.get(cachePath, cacheKey);
1769
- if (cachedNode)
1770
- this.pushEachEntry(r, nid, attrName, key, cachedNode);
1771
- else {
1772
- const dom = this.renderView(view, stack.enter(value, binds, false));
1773
- this.pushEachEntry(r, nid, attrName, key, dom);
1774
- this.cache.set(cachePath, cacheKey, dom);
1775
- }
1765
+ const { iterData, start, end, keys } = unpackLoopResult(loopWith.call(it, seq, makeLoopCtx(stack, filter)), seq);
1766
+ const renderOne = (key, value, attrName) => {
1767
+ const cachePath = enricher ? [view, it, value] : [view, value];
1768
+ const binds = { key, value };
1769
+ const cacheKey = `${nid}-${key}`;
1770
+ if (enricher)
1771
+ enricher.call(it, binds, key, value, iterData);
1772
+ const cachedNode = this.cache.get(cachePath, cacheKey);
1773
+ if (cachedNode)
1774
+ this.pushEachEntry(r, nid, attrName, key, cachedNode);
1775
+ else {
1776
+ const dom = this.renderView(view, stack.enter(value, binds, false));
1777
+ this.pushEachEntry(r, nid, attrName, key, dom);
1778
+ this.cache.set(cachePath, cacheKey, dom);
1776
1779
  }
1777
- }, start, end);
1780
+ };
1781
+ if (keys)
1782
+ imKeysIter(seq, renderOne, keys);
1783
+ else
1784
+ getSeqInfo(seq)(seq, (key, value, attrName) => {
1785
+ if (filter.call(it, key, value, iterData))
1786
+ renderOne(key, value, attrName);
1787
+ }, start, end);
1778
1788
  return r;
1779
1789
  }
1780
1790
  renderView(view, stack) {
@@ -1810,8 +1820,17 @@ var filterAlwaysTrue = (_v, _k, _seq) => true;
1810
1820
  var nullLoopWith = (seq) => ({ iterData: { seq } });
1811
1821
  var unpackLoopResult = (result, seq) => {
1812
1822
  const r = result ?? {};
1813
- return { iterData: r.iterData ?? { seq }, start: r.start, end: r.end };
1823
+ return { iterData: r.iterData ?? { seq }, start: r.start, end: r.end, keys: r.keys };
1824
+ };
1825
+ var imKeysIter = (seq, visit, keys) => {
1826
+ const attrName = isIndexed(seq) ? "si" : "sk";
1827
+ for (const key of keys)
1828
+ visit(key, seq.get(key), attrName);
1814
1829
  };
1830
+ var makeLoopCtx = (stack, filter) => ({
1831
+ lookup: (name) => stack.lookupBind(name),
1832
+ filter: (key, value, iterData) => filter.call(stack.it, key, value, iterData)
1833
+ });
1815
1834
  var imIndexedIter = (seq, visit, start, end) => {
1816
1835
  const [s, e] = normalizeRange(start, end, seq.size);
1817
1836
  for (let i = s;i < e; i++)
@@ -2359,11 +2378,11 @@ class IterInfo {
2359
2378
  return { seq, filter, loopWith, enricher };
2360
2379
  }
2361
2380
  enrichBinds(stack, key) {
2362
- const { seq, loopWith, enricher } = this.eval(stack);
2381
+ const { seq, filter, loopWith, enricher } = this.eval(stack);
2363
2382
  const value = seq?.get ? seq.get(key, null) : null;
2364
2383
  const binds = { key, value };
2365
2384
  if (enricher) {
2366
- const { iterData } = unpackLoopResult(loopWith.call(stack.it, seq), seq);
2385
+ const { iterData } = unpackLoopResult(loopWith.call(stack.it, seq, makeLoopCtx(stack, filter)), seq);
2367
2386
  enricher.call(stack.it, binds, key, value, iterData);
2368
2387
  }
2369
2388
  return binds;
@@ -3714,6 +3733,13 @@ function phaseOps(phase) {
3714
3733
  function resolveArgs(args, self) {
3715
3734
  return typeof args === "function" ? args(self) ?? [] : args ?? [];
3716
3735
  }
3736
+ function phaseHasBubble(phase) {
3737
+ if (!phase)
3738
+ return false;
3739
+ if (phase.bubble?.length)
3740
+ return true;
3741
+ return (phase.do ?? []).some((op) => op.type === "bubble");
3742
+ }
3717
3743
  function dispatchPhase(dispatcher, targetPath, phase, self) {
3718
3744
  if (!phase)
3719
3745
  return;
@@ -4007,7 +4033,8 @@ class FieldSet extends Field {
4007
4033
  }
4008
4034
  function mkCompField(field, scope, args) {
4009
4035
  const Comp = scope?.lookupComponent(field.type) ?? null;
4010
- console.assert(!scope || Comp !== null, "component not found", { field });
4036
+ if (Comp === null)
4037
+ console.warn(scope ? `component field "${field.name}": component "${field.type}" not found in scope` : `component field "${field.name}": cannot resolve component "${field.type}" — built without a registered scope (use ${field.type}.make({}) as the default, or build via a registered component)`);
4011
4038
  return Comp?.make({ ...field.args, ...args }, { scope }) ?? null;
4012
4039
  }
4013
4040
 
@@ -4156,6 +4183,7 @@ export {
4156
4183
  removeIn,
4157
4184
  remove,
4158
4185
  phaseOps,
4186
+ phaseHasBubble,
4159
4187
  mergeWith,
4160
4188
  mergeDeepWith,
4161
4189
  mergeDeep,