vapor-chamber 1.20.0 → 1.22.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +862 -0
  2. package/README.md +49 -27
  3. package/ROADMAP.md +11 -9
  4. package/bin/vc-vitest-mcp.mjs +20 -0
  5. package/dist/LICENSE.txt +6 -2
  6. package/dist/chamber.d.ts.map +1 -1
  7. package/dist/chamber.js.map +1 -1
  8. package/dist/command-bus.d.ts +21 -1
  9. package/dist/command-bus.d.ts.map +1 -1
  10. package/dist/command-bus.js +5 -5
  11. package/dist/command-bus.js.map +1 -1
  12. package/dist/directives.d.ts +154 -14
  13. package/dist/directives.d.ts.map +1 -1
  14. package/dist/directives.js +2 -2
  15. package/dist/directives2.js +53 -20
  16. package/dist/directives2.js.map +1 -1
  17. package/dist/index.js +19 -63
  18. package/dist/index.js.map +1 -1
  19. package/dist/mcp.d.ts +1 -1
  20. package/dist/mcp.js +2 -2
  21. package/dist/mcp.js.map +1 -1
  22. package/dist/plugins-core.d.ts.map +1 -1
  23. package/dist/plugins-extra.d.ts.map +1 -1
  24. package/dist/plugins-io.d.ts +73 -12
  25. package/dist/plugins-io.d.ts.map +1 -1
  26. package/dist/plugins-io.js +40 -67
  27. package/dist/plugins-io.js.map +1 -1
  28. package/dist/schema.d.ts.map +1 -1
  29. package/dist/settled.d.ts +57 -0
  30. package/dist/settled.d.ts.map +1 -1
  31. package/dist/settled.js +8 -0
  32. package/dist/settled.js.map +1 -0
  33. package/dist/ssr.d.ts.map +1 -1
  34. package/dist/ssr2.js +3 -3
  35. package/dist/ssr2.js.map +1 -1
  36. package/dist/testing.d.ts.map +1 -1
  37. package/dist/utilities.d.ts.map +1 -1
  38. package/dist/vapor-chamber-core.iife.js +18 -18
  39. package/dist/vapor-chamber-core.iife.js.map +1 -1
  40. package/dist/vapor-chamber-core.iife.min.js +1 -1
  41. package/dist/vapor-chamber-elements.iife.js +18 -18
  42. package/dist/vapor-chamber-elements.iife.js.map +1 -1
  43. package/dist/vapor-chamber-elements.iife.min.js +1 -1
  44. package/dist/vapor-chamber.iife.js +96 -84
  45. package/dist/vapor-chamber.iife.js.map +1 -1
  46. package/dist/vapor-chamber.iife.min.js +1 -1
  47. package/dist/vite-hmr.d.ts +53 -0
  48. package/dist/vite-hmr.d.ts.map +1 -1
  49. package/dist/vite-hmr.js +77 -1
  50. package/dist/vite-hmr.js.map +1 -1
  51. package/dist/vitest-mcp.d.ts +120 -0
  52. package/dist/vitest-mcp.d.ts.map +1 -0
  53. package/dist/vitest-mcp.js +213 -0
  54. package/dist/vitest-mcp.js.map +1 -0
  55. package/dist/vitest-pure.d.ts +344 -0
  56. package/dist/vitest-pure.d.ts.map +1 -0
  57. package/dist/vitest-pure.js +377 -0
  58. package/dist/vitest-pure.js.map +1 -0
  59. package/dist/vitest.d.ts +120 -0
  60. package/dist/vitest.d.ts.map +1 -0
  61. package/dist/vitest.js +18 -0
  62. package/dist/vitest.js.map +1 -0
  63. package/package.json +38 -11
  64. package/scripts/ascii-table.mjs +137 -0
  65. package/scripts/bench-ratios-reporter.mjs +57 -2
  66. package/scripts/build.mjs +53 -2
  67. package/scripts/check-ascii.mjs +153 -58
  68. package/scripts/check-console-shape.mjs +431 -0
  69. package/scripts/check-env-guards.mjs +5 -1
  70. package/scripts/check-example-directive.mjs +181 -0
  71. package/scripts/check-size.mjs +16 -3
  72. package/scripts/gate.mjs +256 -0
  73. package/scripts/measure-size.mjs +4 -1
  74. package/src/chamber.ts +2 -8
  75. package/src/command-bus.ts +43 -20
  76. package/src/directives.ts +472 -66
  77. package/src/mcp.ts +1 -1
  78. package/src/plugins-core.ts +35 -42
  79. package/src/plugins-extra.ts +8 -11
  80. package/src/plugins-io.ts +119 -91
  81. package/src/schema.ts +14 -3
  82. package/src/settled.ts +73 -1
  83. package/src/ssr.ts +17 -3
  84. package/src/testing.ts +19 -22
  85. package/src/utilities.ts +3 -4
  86. package/src/vite-hmr.ts +168 -0
  87. package/src/vitest-mcp.ts +302 -0
  88. package/src/vitest-pure.ts +741 -0
  89. package/src/vitest.ts +155 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,868 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## v1.22.0 - 2026-09-21 Vue 3.6.0-rc.9 alignment
6
+
7
+ 89 commits in `v3.6.0-rc.8..v3.6.0-rc.9` (the full log, not first-parent; the
8
+ release notes list fewer, as at rc.8). Sixteen were read in full or closed by a
9
+ probe or a committed test; the rest are closed from their title and touched files
10
+ and are marked as such in the cycle record. Peer range narrows to
11
+ `>=3.5.0 || >=3.6.0-rc.9` for `vue` and `@vue/reactivity`; both Vapor examples
12
+ repinned and rebuilt.
13
+
14
+ ### `v-vc-payload` and `v-vc-optimistic` now work on Vapor
15
+
16
+ Two new exports, `vcPayloadVapor` and `vcOptimisticVapor`, beside
17
+ `vcCommandVapor`. All three directives now mean the same thing on both
18
+ renderers, and there is nothing left to document as unavailable over there.
19
+
20
+ ```vue
21
+ <script setup vapor>
22
+ import {
23
+ vcCommandVapor as vVcCommand,
24
+ vcPayloadVapor as vVcPayload,
25
+ vcOptimisticVapor as vVcOptimistic,
26
+ } from 'vapor-chamber/directives'
27
+ </script>
28
+ <template>
29
+ <button v-vc-command="'cartAdd'" v-vc-payload="{ qty, orderedAt: new Date() }"
30
+ v-vc-optimistic="bump">Add</button>
31
+ </template>
32
+ ```
33
+
34
+ **What was actually missing.** `v-vc-payload` had a substitute and
35
+ `v-vc-optimistic` had none. The substitute was lossy in a way that is easy to
36
+ miss: `data-vc-payload` is JSON in an attribute, so measured through it a
37
+ `Date` arrives as a string, a `Map` as `{}`, a class instance as a plain object,
38
+ and a function and an `undefined` key disappear entirely. A handler calling
39
+ `cmd.payload.when.getTime()` worked through the binding and threw through the
40
+ attribute. For optimistic-with-rollback there was no route at all: the Vapor
41
+ consumer had to drop `v-vc-command` for that button and hand-roll the dispatch,
42
+ forfeiting `vc-loading` / `vc-error`, disable-while-busy, the re-entrancy guard,
43
+ the timeout and the modifiers - and it could not be done by adding an `@click`
44
+ beside the directive, because since [#15490]'s sibling `80b3a046` that handler
45
+ registers first and can veto the dispatch.
46
+
47
+ **Read at dispatch time**, like the action, because Vapor has no `updated` hook.
48
+ A changed payload re-targets the next click without the directive re-running. A
49
+ binding beats `data-vc-payload` on both renderers, unchanged.
50
+
51
+ **Order does not matter.** `<button v-vc-payload="p" v-vc-command="a">` works,
52
+ and so does the reverse. That is not free and it is not defensive: a compiled
53
+ template applies directives in SOURCE order and mounting the command clears the
54
+ element's state first, so the naive port loses a payload written before the
55
+ command - a button that dispatches with nothing, silently. A warning was
56
+ considered and rejected; attribute order is not something a consumer has reason
57
+ to think matters. Both orders are pinned by tests.
58
+
59
+ **Cost**, same measurement as the reshape above (Vite production consumer
60
+ bundle of `./directives`, brotli q11): **+500 raw / +125 brotli**. The three
61
+ IIFE variants are byte-identical - none carries directives code. The `.d.ts`
62
+ grows 3,238 B, which is types and JSDoc and reaches no consumer's bundle.
63
+
64
+ **The alias rule applies to these too, and it fails silently.** Vue camelCases
65
+ the whole directive name, so the imports must alias as `vVcPayload` and
66
+ `vVcOptimistic`. Anything shorter compiles, type-checks, and falls back to a
67
+ directive nobody registered - which Vue warns about in DEV and not in a
68
+ production build. `npm run check:example` now clicks the example's button and
69
+ asserts the optimistic counter moved by the payload's `qty`, so a dead binding
70
+ shows up as 1 instead of 3 rather than as nothing at all.
71
+
72
+ ### Fixed: on vDOM, a payload written before the command was silently dropped
73
+
74
+ The order-independence above was Vapor's alone. On the vDOM plugin
75
+ `<button v-vc-payload="p" v-vc-command="a">` dispatched with **no payload**,
76
+ and `v-vc-optimistic` before the command **never ran** - no warning, no error,
77
+ a control that renders and quietly does less than it says.
78
+
79
+ Measured against the real vDOM runtime, first click, no re-render since mount:
80
+
81
+ <button v-vc-command v-vc-payload> payload {qty:3} delivered
82
+ <button v-vc-payload v-vc-command> payload undefined DROPPED
83
+ <button v-vc-optimistic v-vc-command> optimistic never ran
84
+
85
+ **Why it survived.** After any re-render the `updated` hook re-applies the
86
+ binding and both recover, so it is a FIRST-CLICK defect: every test that
87
+ renders before it asserts passes, and the source said in as many words that
88
+ the vDOM half "needs nothing equivalent" because the next patch would fix it.
89
+ That is true from the second click. The element is clickable before the first,
90
+ and on a page with no reactive state - the Blade / sprinkled shape this library
91
+ exists to serve - there is no next patch and it never works at all.
92
+
93
+ **What it cost beyond the payload.** One public spelling meant two things:
94
+ order-independent on Vapor, dead on vDOM. `examples/vapor-sfc` writes
95
+ payload-before-command and states that the order does not matter, which holds
96
+ only because that file is `<script setup vapor>`. A reader copying that markup
97
+ into a vDOM component got a silent no-op. This is the asymmetry the section
98
+ above exists to close, reopened from the other renderer.
99
+
100
+ **The fix** is the one Vapor already had: the holding slot now takes vDOM
101
+ values as well as Vapor getters, and both registrations write through it, so a
102
+ binding that arrives before the command is held and claimed instead of
103
+ discarded. `payload` and `optimisticFn` join the state literal, so the hidden
104
+ class no longer depends on which renderer mounted the element.
105
+
106
+ Same measurement as above (Vite production consumer bundle of `./directives`,
107
+ brotli q11):
108
+
109
+ before raw 27,435 brotli 7,324
110
+ after raw 27,419 brotli 7,340
111
+ delta -16 +16
112
+
113
+ Raw shrinks because four repeated hook bodies collapse into one shared writer;
114
+ brotli grows by the same small amount because that repetition compressed almost
115
+ perfectly. All three IIFE variants are byte-identical before and after - none
116
+ carries directives code, so they are the control, not the target.
117
+
118
+ Four tests pin it: both before-command cases (the regression), the
119
+ after-command case (the control, because the cheapest wrong fix swaps which
120
+ order works), an `updated` after a claim, and an element that never mounts a
121
+ command at all.
122
+
123
+ ### BREAKING: the directive selector moved into the NAME
124
+
125
+ **`v-vc:command` is now `v-vc-command`.** The other two directives were always
126
+ spelled that way, so the three names finally have one shape:
127
+
128
+ v-vc-command v-vc-payload v-vc-optimistic
129
+
130
+ Registered as `vc-command`, `vc-payload` and `vc-optimistic`. There is no
131
+ deprecation alias, deliberately: the alias would have to keep the exact
132
+ machinery this change exists to delete.
133
+
134
+ **Why.** Vue's directive ARGUMENT is a *parameter* slot - `v-bind:href`,
135
+ `v-on:click` - meant to be dynamic, and reactive since [#15490]. This package
136
+ was using it as a *selector*: the argument said which of `command` / `payload` /
137
+ `optimistic` a binding was. That is a category error, and the defect below is
138
+ downstream of it. A selector must not move; a parameter is built to. The two
139
+ requirements were in direct conflict, and the conflict shipped a dead control.
140
+
141
+ **It is a deletion, not a rename.** Gone with the argument read: the
142
+ `argument()` call, the DEV guard on a non-getter argument, the below-floor Vue
143
+ version warning that existed only to explain that guard, both `binding.arg`
144
+ guards in the vDOM registration, and the documented `v-vc:[kind]` latch -
145
+ unreachable once there is no argument to be dynamic.
146
+
147
+ Measured on a Vite production consumer bundle of `./directives`, brotli q11:
148
+
149
+ before raw 21,722 brotli 7,126
150
+ after raw 21,335 brotli 6,982
151
+ delta -387 -144
152
+
153
+ All three IIFE variants are byte-identical: none of them carries directives
154
+ code, so they serve as the control rather than the target.
155
+
156
+ **`v-vc:payload` was inert, and this is what fixes it.** The drift the split
157
+ caused: `v-vc:payload` compiled to `[_directive_vc, _ctx.p, "payload"]` - the
158
+ `vc` directive with an argument its `mounted` rejected - so it did nothing,
159
+ while being documented in the README, the whitepaper, an example header and the
160
+ generated API reference. The working spelling was always `v-vc-payload`. It now
161
+ compiles to its own `resolveDirective("vc-payload")`, and two new tests compile
162
+ the notation a consumer actually types rather than resolving a name we chose.
163
+
164
+ **It also closes the vue-tsc gap for this directive**, which was not the point
165
+ and is worth stating carefully. vue-tsc type-checked the template call by
166
+ passing the ARGUMENT as a raw string - the pre-[#15490] shape - and rejected the
167
+ idiomatic `import { vcCommandVapor as vVc }` form with TS2345. With no argument
168
+ there is nothing to mis-check. A/B in the example's own project, with a control:
169
+ the colon form still reports TS2345, the hyphen form exits clean, and a
170
+ deliberate error in the same probe still reports TS2339, so the template is
171
+ genuinely being checked. **The tool has not caught up** - the bug is still there
172
+ for anyone using an argument. Our usage no longer has the shape that triggers it.
173
+
174
+ **Upgrading.** Rename the attribute in your templates, and the registration if
175
+ you register app-wide:
176
+
177
+ - <button v-vc:command="'cartAdd'">
178
+ + <button v-vc-command="'cartAdd'">
179
+
180
+ - createVaporApp(App).directive('vc', vcCommandVapor)
181
+ + createVaporApp(App).directive('vc-command', vcCommandVapor)
182
+
183
+ **The local binding name changes too, and it fails silently if you miss it.** Vue
184
+ resolves an SFC directive by camelCasing the FULL name, so `v-vc-command` looks for
185
+ `vVcCommand`. Under the old spelling the directive was named `vc` and `command` was
186
+ its argument, so the binding was `vVc`:
187
+
188
+ - import { vcCommandVapor as vVc } from 'vapor-chamber/directives'
189
+ + import { vcCommandVapor as vVcCommand } from 'vapor-chamber/directives'
190
+
191
+ An import left aliased to `vVc` compiles, type-checks and mounts nothing - the same
192
+ silent dead control [#15490] produced. This was caught here by
193
+ `npm run check:example`, which clicks the built example's button and asserts the
194
+ directive responded, and by nothing else.
195
+
196
+ `v-vc-payload` and `v-vc-optimistic` are unchanged. If you wrote `v-vc:payload`
197
+ or `v-vc:optimistic` anywhere, those bindings were doing nothing; the hyphenated
198
+ spelling is what makes them work.
199
+
200
+ ### `v-vc:command` was a dead control in every compiled Vapor template
201
+
202
+ **The one defect rc.9 caused here, and it was silent.** [#15490] made a Vapor
203
+ custom directive's ARGUMENT a getter, so compiler-vapor emits
204
+
205
+ rc.8 [_directive_vc, () => (_ctx.action), "command", { stop: true }]
206
+ rc.9 [_directive_vc, () => (_ctx.action), () => ("command"), { stop: true }]
207
+
208
+ and `vcCommandVapor` opened with `if (argument !== 'command') return;`. A function
209
+ never equals that string, so the directive returned before mounting: no listener,
210
+ no state, no warning, and no error anywhere. Every `v-vc:command` in a compiled
211
+ Vapor template did nothing.
212
+
213
+ - **The getter, and only the getter.** Vue's own `VaporDirective` type declares
214
+ `argument?: () => Arg` with no union, so the string is not a legacy shape but an
215
+ illegal one; no Vue inside this package's peer range can emit it (3.5 ships
216
+ neither runtime-vapor nor compiler-vapor, and the second arm of the range starts
217
+ at rc.9). Accepting both would be a branch no installable Vue can reach, provable
218
+ only by a hand-written literal - the construct that hid this for a release. A DEV
219
+ guard names the required version instead, and folds to nothing in a consumer's
220
+ production build (measured in a Vite production bundle: neither the guard string
221
+ nor the version string survives, and no unfolded `typeof process` or
222
+ `import.meta.env` is left).
223
+ - **Why 2,431 passing tests did not see it.** Both Vapor directive fixtures
224
+ hand-wrote the tuple, copied from an older compiler release, and their headers
225
+ said so. A literal records a compiler release and then agrees with it forever.
226
+ `tests/compile-vapor.ts` is the fix: one helper that compiles the consumer's
227
+ template on the INSTALLED Vue and reads the generated module's imports rather
228
+ than listing them. Every Vapor test that matters now runs compiler output.
229
+ - **An example carries one now.** `examples/vapor-sfc` had no `v-vc:command`
230
+ anywhere, so rebuilding all three examples - which this project's alignment
231
+ ritual does every cycle - passed while the feature was dead.
232
+ `npm run check:example` builds that example and drives the button in a real
233
+ page, so the next time it goes inert something fails.
234
+
235
+ ### Teardown is keyed to what was mounted, never to the current binding
236
+
237
+ Found while measuring the fix above, and older than rc.9: the vDOM `v-vc`'s
238
+ `beforeUnmount` guarded on `binding.arg !== 'command'`, and Vue passes the LATEST
239
+ binding - so after a dynamic argument moved off `command`, teardown never ran and
240
+ the element's listener outlived it. Honest scope: reachable only through
241
+ `v-vc:[kind]`, which nothing uses; the delegated refcount is otherwise complete and
242
+ correct, with plain unmount, partial, full and double-unmount all measured correct
243
+ BEFORE the fix. The fix is the deletion of that line - `unmountCommand` already
244
+ no-ops on an element with no state, so the guard bought nothing. Net -56 B. A
245
+ dynamic `v-vc:[kind]` LATCHES on both renderers alike and is documented rather
246
+ than warned about.
247
+
248
+ ### One listener-options object
249
+
250
+ Recorded at mount and handed to both `addEventListener` and `removeEventListener`,
251
+ replacing a `capture` boolean that existed only to rebuild what was already there
252
+ (-16 B). `removeEventListener` is a silent no-op when the handler or the capture
253
+ flag differ, and the two calls had disagreed harmlessly (`{}` against `undefined`),
254
+ which had forced the test comparing them to compare capture flags rather than
255
+ arguments. A comparison weakened to pass will not catch what it is for: add
256
+ `{ once: true }` to one side and removal stops matching, with no error.
257
+
258
+ ### Fixtures that run compiler output
259
+
260
+ New, all driving compiled templates on the installed Vue rather than hand-written
261
+ tuples: `tests/vapor-keepalive-fixture.test.ts` (a cached component swapped out of
262
+ the slot does NOT dispose the directive's scope; reactivation reuses element and
263
+ state; cleanup runs exactly once on each of three teardown routes - include/exclude
264
+ drop, `max` eviction, and a nested branch torn down after reactivation - counting
265
+ cleanup invocations, not listener removals, because with one delegated element a
266
+ second teardown hits an early return and leaves no trace), and
267
+ `tests/vapor-hydration-fixture.test.ts` (genuinely server-rendered: compiled with
268
+ `ssr: true`, rendered to a string by `@vue/server-renderer`, only the string
269
+ crossing to a client that hydrates with `createVaporSSRApp`; the directive adopts
270
+ the server's element, mounts once, dispatches, no mismatch warnings, teardown
271
+ balanced). `tests/directives-vapor-fixture.test.ts` gains keyed and unkeyed v-for:
272
+ a keyed reorder reuses nodes with no re-run and no cleanup, a filter runs exactly
273
+ one cleanup, and in an UNKEYED list the element is reused while the item shifts
274
+ under it and the click follows the item - which a keyed reorder cannot show, since
275
+ it moves the node and a latched value would look identical.
276
+
277
+ ### `createDirectivePlugin()` on a Vapor app now says so, in DEV
278
+
279
+ Installing the vDOM plugin on a Vapor app leaves every `v-vc-command` inert.
280
+ From rc.9 Vue skips a vDOM object directive in a Vapor template with a warning
281
+ of its own (#15489); before that it called the object and threw a TypeError at
282
+ mount. Vue names the symptom - "Received a VDOM object directive" - so ours
283
+ carries only the fix: **"On a Vapor app use vcCommandVapor."**
284
+
285
+ One own-property read (`app.vapor`, which a Vapor app carries and a vDOM app
286
+ does not), and the whole branch folds out of a production build. Measured on
287
+ `dist/directives2.js`: **+94 B raw / +21 B brotli**, of which the `if` costs 8
288
+ and the string 13. All three IIFE variants and the tree-shake consumer bundle
289
+ are byte-identical - none of them carries directives code.
290
+
291
+ Also worth knowing on Vapor: all three directives have a Vapor registration as
292
+ of this release - `vcCommandVapor`, `vcPayloadVapor` and `vcOptimisticVapor` -
293
+ so the remedy is always an import rather than a workaround. The ROADMAP feature
294
+ matrix carries a row for each.
295
+
296
+ ### Upgrade note: a template `@click` now runs before `v-vc-command`
297
+
298
+ **Scope: only an element carrying BOTH a template `@click` and
299
+ `v-vc-command`.** Nothing else changes. No example or documented recipe pairs
300
+ them - `examples/feature-directives.html` says in as many words that there is
301
+ no click handler anywhere in it, because the directive owns the whole
302
+ interaction.
303
+
304
+ `80b3a046` moved compiled custom directives to the END of a block, after the
305
+ element's props, children and `v-model`. Measured on one template with both
306
+ compilers: rc.8 emitted `_withVaporDirectives` and then `_on`; rc.9 emits them
307
+ the other way round. The directive's listener ran FIRST on rc.8 and runs
308
+ SECOND on rc.9, so a template handler now gets to veto the dispatch through
309
+ `buildHandler`'s disabled / `aria-disabled` / in-flight guard - deliberate
310
+ since v1.6.0, mirroring Vue's #14948, and unchanged. The platform does not
311
+ retract an event already in flight, so disabling the element from that handler
312
+ does not stop the click reaching us - the guard is what stops the dispatch.
313
+
314
+ What each kind of veto does, measured on one element carrying both:
315
+
316
+ | the `@click` handler does | vDOM | Vapor direct | Vapor `.delegate` |
317
+ | --- | --- | --- | --- |
318
+ | nothing | dispatches | dispatches | dispatches |
319
+ | `el.disabled = true` | skips | skips | skips |
320
+ | `stopImmediatePropagation()` | skips | skips | skips |
321
+ | `stopPropagation()` | dispatches | dispatches | **skips** |
322
+
323
+ **This makes Vapor match vDOM rather than depart from it.** Vue patches an
324
+ element's props before it runs a directive's `mounted`, so the vDOM
325
+ registration has run second all along; on rc.8 neither veto reached the Vapor
326
+ directive at all (measured: both dispatched).
327
+
328
+ **`.delegate` is unaffected by the rc.9 reordering, and always was.** Its
329
+ listener is on the DOCUMENT and fires during bubbling, after every
330
+ element-level listener whatever order they registered in, so the emission
331
+ order never decided anything for it. The same property is why `.delegate` is
332
+ the one mode plain `stopPropagation()` on the element vetoes: it stops the
333
+ event reaching the document, while a direct listener on that same element is
334
+ not stopped by it. **Both facts are pre-existing and inherent to delegation** -
335
+ neither was introduced by rc.9, and neither changes with it.
336
+
337
+ If you want the dispatch to happen regardless, move the `@click` work into the
338
+ command handler, or put it on a wrapper element.
339
+
340
+ ### Inherited from rc.9, no code change here
341
+
342
+ Each reaches consumers only through an API this package re-exports:
343
+
344
+ - **`958580bf`** - a Vapor component's props are marked shallow, so `watch(props)`
345
+ is no longer deep. Nothing here watches props or teaches it (grepped across src,
346
+ examples, docs and README), but an app that does, through
347
+ `defineVaporComponent`, gets the shallower watcher.
348
+ - **`ab722d18`** - reserved props are skipped in attrs and dynamic props.
349
+ - **`0e4ff650`** - an empty-string dynamic component renders an empty branch;
350
+ `router/vapor` already passes a getter that can answer null, and this is the
351
+ same arbitration one value further out.
352
+ - **`90ddc697`** - hydration advances past a multi-root async component.
353
+
354
+ ### BREAKING: `sync` carries facts, not commands, and leaves the dispatch chain
355
+
356
+ `sync` was a bus plugin that re-broadcast every successful dispatch and
357
+ re-dispatched it in the receiving tab. That replicates INTENT: each tab re-runs
358
+ the handler and derives its own outcome. Measured against a real
359
+ `BroadcastChannel` rather than the channel stub the old tests used, three things
360
+ follow, and none of them were documented:
361
+
362
+ - A handler that is not deterministic does not mirror. Two tabs running the same
363
+ `cartAdd` minted `A-line-1-936891` and `B-line-1-675288` and stayed different.
364
+ - A tab seeded differently stays different: one ended at 1, the other at 6.
365
+ - A handler that dispatches a nested command applied that derivation **twice per
366
+ tab**, because each tab derived its own and then received the peer's.
367
+
368
+ Suppressing the receive side alone does not fix the third (6 runs become 5, not
369
+ 4): the ORIGINATING tab is still broadcasting its derivations. Telling a root
370
+ dispatch from a derived one by inference needs the core to distinguish them,
371
+ which it does not - a root and a nested dispatch carry identical meta - and
372
+ adding a counter to do so taxes every dispatch on the bus.
373
+
374
+ **So the app declares what crosses instead.** A handler computes a fact and emits
375
+ it on an event channel; the bridge puts that fact on the wire and every other tab
376
+ applies it. A derivation is not a fact unless the app emits it, so there is
377
+ nothing to infer and no counter to pay for, and the receiving tab applies values
378
+ rather than recomputing them - the ordinary CQRS split between a command and
379
+ something that already happened.
380
+
381
+ ```typescript
382
+ const lane = createFastLane()
383
+ lane.on('cartAdded', fact => applyToCart(fact)) // local AND remote land here
384
+ bus.register('cartAdd', cmd => { lane.emit('cartAdded', computeAdd(cmd.target)) })
385
+ const tabSync = sync({ channel: 'vc:app', lane, events: ['cartAdded'] })
386
+ ```
387
+
388
+ **It is also roughly twice as fast, and that is the same change.** As a plugin
389
+ this cost more than half the bus's dispatch throughput, on every dispatch of
390
+ every action, whether or not it synced. Over 11 shuffled rounds of 200,000
391
+ dispatches with `gc()` per round, against a byte-identical self-control arm:
392
+
393
+ | wiring | ops/s | ratio vs bare |
394
+ |---|--:|--:|
395
+ | bare bus | 28,779,223 | 1.000 |
396
+ | as a plugin (before) | 13,199,687 | 0.459 |
397
+ | self-control, identical code | 13,062,433 | 0.454 |
398
+ | `onAfter` listener | 15,486,719 | 0.538 |
399
+ | **fast lane (now)** | **24,965,803** | **0.867** |
400
+
401
+ Migration: `sync(opts, busRef)` becomes `sync({ channel, lane, events })` and is
402
+ no longer passed to `bus.use()`. `filter` is gone - `events` names what crosses.
403
+ `onReceive(cmd)` becomes `onReceive(event, data)`. `_withOrigin`'s sync usage and
404
+ the `meta.origin === 'sync'` echo check are both deleted: a lane emit is
405
+ synchronous, so a plain boolean suppresses the echo with no window for the async
406
+ race that forced the origin marker.
407
+
408
+ **What it still does not do.** Facts mirror; seeds do not. Tabs converge only if
409
+ the facts are absolute ("the count is 2") rather than relative ("add one"). And a
410
+ payload crosses through structured clone, so it cannot carry functions - a
411
+ non-cloneable payload warns in DEV, naming the event, and never takes the local
412
+ emit down with it.
413
+
414
+ ### The argument guard is DEV-only, and the state literal is one shape
415
+
416
+ Two lib-side items in `vcCommandVapor`'s mount path, found by re-reading this
417
+ cycle's own changes against the built output rather than the source.
418
+
419
+ - **The argument-shape guard shipped to production.** Only the `console.warn` was
420
+ gated, so `if (argument !== undefined && typeof argument !== 'function') return;`
421
+ survived a production build - a branch this entry itself argues no installable
422
+ Vue can reach. Measured on `./directives`, prod-minified with DEV folded off:
423
+ 37 bytes min, 12 gzip, 14 brotli. The behaviour ran the same way: `return` on a
424
+ string is a control that renders, clicks and does nothing, with no error, which
425
+ is the exact shape [#15490] left behind. Now DEV-only. Production reaches the
426
+ bare `argument()` and throws at the line that caused it.
427
+ - **The state literal gained a hidden-class transition.** `capture` left the
428
+ literal when it became `listenerOpts`, and the write moved after it,
429
+ transitioning V8's map once per mount on the direct-listener path;
430
+ `delegatedDoc` had the same shape on the delegated path. Both are declared in
431
+ the literal now, so every `DirectiveState` leaves `mountCommand` with one map.
432
+ Not benched and not claimed as faster: it removes a transition, it cannot be
433
+ slower, and it is not a size loss - two `undefined` slots cost 40 bytes raw and
434
+ give back 3 brotli.
435
+
436
+ Net on `./directives`, prod-minified with DEV folded: -3 brotli, -2 gzip, +3 min.
437
+
438
+ ### A second tooling gap: `tests/` is not typechecked
439
+
440
+ `tsconfig.json` is `include: ["src/**/*"]` and `tsconfig.typecheck.json` adds two
441
+ named test files, so no other test is ever type-checked. The `sync` rewrite made
442
+ this visible: 21 call sites kept compiling against a signature that no longer
443
+ existed and failed only at runtime. Recorded, not fixed here.
444
+
445
+ ### The `onSettled` sweep missed two plugins, and SSR was dead on an async bus
446
+
447
+ `src/settled.ts` records a defect found in five shipped plugins: they read
448
+ `next()`'s return value as though it were the result, and on an async bus
449
+ `next()` returns a PROMISE, so `promise.ok` is `undefined`. It was fixed with
450
+ one helper, and that file's closing line is that a rule applied by hand at each
451
+ call site goes missing wherever nobody remembered it.
452
+
453
+ **The sweep itself was one of those hands.** It was run over the plugin family -
454
+ `plugins-core.ts`, `plugins-extra.ts`, `plugins-io.ts` - and two plugins live
455
+ outside it. Both had the identical defect, for two releases:
456
+
457
+ - **`createSSRPlugin()` recorded NOTHING on an async bus.** `dehydrate()` came
458
+ back empty, so the client rehydrated no state at all. Measured through the
459
+ public API, same dispatch, both bus kinds:
460
+
461
+ sync dehydrate: [{"action":"cartAdd","target":{"id":1}}]
462
+ async dehydrate: []
463
+
464
+ No error, no warning. It reads exactly like a render that had no commands to
465
+ record, which is the same shape as every other defect in this class.
466
+ - **`schemaLogger()` printed the error branch for every command,** successes
467
+ included, with `undefined` as the error - the defect `logger()` had, three
468
+ modules away from `logger()`.
469
+
470
+ Both now go through `onSettled`. The SSR fix is armed by a test that fails
471
+ without it; the schema one asserts the logged value is `'added'` rather than
472
+ `undefined`.
473
+
474
+ **The rule is now a test, not a memory.** `tests/settled-sweep.test.ts` scans
475
+ every `.ts` under `src/` for a `next()` result that is bound and then read
476
+ without being settled, and separately asserts the scan covers every module that
477
+ produces a `Plugin` - sixteen of them, not the three whose filenames begin with
478
+ `plugins-`. Comments are blanked before scanning, because `settled.ts`'s own
479
+ docblock quotes the pattern it looks for.
480
+
481
+ **Why the type system did not catch it, and still would not.** `Plugin` declares
482
+ `next: () => CommandResult` while being usable on either bus, which is a lie on
483
+ the async one; `settled.ts` already says so. Three plugins work around it with
484
+ `const plugin: any`, which is the type system being escaped rather than
485
+ modelled. Collapsing `Plugin` into `AsyncPlugin` does not work either: the sync
486
+ runner (`buildRunner`) genuinely requires a synchronous return, so the two are
487
+ different types and a dual-capable plugin is neither - it needs an overloaded
488
+ signature. Not attempted here. This is also the root of 19 of the type errors
489
+ the `tests/` typecheck gap hides, so the two are one piece of work.
490
+
491
+ ### The prototype-key rule is a sweep too, before it needs to be
492
+
493
+ `src/dict.ts` is `src/settled.ts` one release earlier, and the shape is
494
+ identical: a bug class found six times, each site "written as if it were the
495
+ first", centralized into one helper, the rule stated in prose, and the known
496
+ sites pinned by `tests/prototype-keys.test.ts` - which says outright that it
497
+ "pins the two non-router sites". Nothing stood between the codebase and site
498
+ seven except somebody remembering.
499
+
500
+ Swept, and it currently holds: no module tests membership with `in` or with
501
+ `!== undefined`, no prototype-free object is spread back into a plain one, and
502
+ all six sites `dict.ts` names call the helper. That is the same thing that was
503
+ true of `onSettled` right up until two modules grew a `Plugin` outside the
504
+ family someone had grepped, so `tests/dict-sweep.test.ts` now holds it instead
505
+ of a person. Each of its three assertions is armed by injecting the construct
506
+ it bans into a real module and watching the sweep fail.
507
+
508
+ Two things the sweep needs that are worth stating, because both produced false
509
+ readings first. String literals have to be blanked as well as comments: without
510
+ that the scan reports 26 sites, every one an error message containing the word
511
+ "in" ("Retry in 200ms"). And a TypeScript mapped type `[K in keyof T]` is not a
512
+ membership test.
513
+
514
+ **The general rule this repository keeps rediscovering.** A bug class fixed by
515
+ writing the rule down is not fixed; it is deferred to whoever reads the prose
516
+ next. Two helpers now exist solely to centralize such a rule, and both had the
517
+ same gap. From here, a helper introduced to centralize a bug class ships with a
518
+ sweep that scans every module for the construct it replaces - not a test per
519
+ known site, and not a grep scoped to the filenames that happened to be involved
520
+ the first time.
521
+
522
+ ### The console-argument convention is a sweep too, before it needs to be
523
+
524
+ The third instance of the shape the two sections above describe, and this one
525
+ was swept before a single site had broken it. The convention: identifying text
526
+ (an action name, a key, a count, a limit, a code) is interpolated into the
527
+ message, and a value with structure (a caught error, an element, a command, a
528
+ result, an options bag) is passed as a SEPARATE console argument, where devtools
529
+ renders it live and expandable instead of flattening it to "[object Object]".
530
+
531
+ Censused 2026-09-22 over every `console.*` call in `src/`: every interpolated
532
+ value is identifying text, every inspectable value is already a second argument,
533
+ and `JSON.stringify`, `String(...)` and `.toString()` appear zero times inside a
534
+ console argument. Nothing held that but memory, so
535
+ `scripts/check-console-shape.mjs` holds it now, in `lint:check` beside the other
536
+ guards. It prints its own counts, so the numbers here cannot go stale.
537
+
538
+ It classifies by TYPE rather than by a list of names that look inspectable: a
539
+ value may be interpolated only if the type checker says it is a string, number,
540
+ boolean or bigint, or a union of those - which is what a string enum and
541
+ `typeof x` are - and everything else fails, `any` and `unknown` included. That
542
+ is `check-ascii.mjs`'s lesson applied on purpose: an alphabet is worth exactly
543
+ the characters somebody thought of, and that list has been wrong here three
544
+ times. The two calls that look like exceptions need no allowlist entry, because
545
+ they pass on shape: `validateNaming`'s `console.warn(msg)` interpolates nothing
546
+ at the call, and the router's `console.error(error)` is the convention itself.
547
+
548
+ **The arming is in the guard, not in a session's notes.**
549
+ `--self-test` injects five violations into real modules - an object in a
550
+ template, `JSON.stringify` in a message, `String()` around a value, and both
551
+ exempt sites rewritten into the violating shape - confirms each is caught, and
552
+ restores every file by copy in a `finally`, asserting the restore rather than
553
+ assuming it. It refuses to start unless git says every file it will write is
554
+ clean: a `finally` does not run on SIGKILL, so an interrupted run would leave an
555
+ injected defect in a real module, and this repo has had two sessions editing one
556
+ working tree at once. Refusing also makes that trace DETECTABLE - the file shows
557
+ as modified next time rather than being quietly re-mutated on top. The two exempt sites are in there because a shape-based exemption
558
+ that quietly matches too much is indistinguishable from a working check; that is
559
+ the `onSettled` sweep's filename-scoping failure in another costume. It is not
560
+ in `lint:check` (it rebuilds the program per mutation and writes to `src/`) and
561
+ not in the suite: no test here drives a `scripts/check-*.mjs`, and a
562
+ second-long `ts.createProgram` does not belong in a vitest run. Blunt the
563
+ classifier and the gate still reports OK while `--self-test` exits 1 - measured,
564
+ which is the point of having it.
565
+
566
+ `CONTRIBUTING.md` opened its `lint:check` paragraph with "Four guards" and then
567
+ named exactly four. The fifth made both wrong, and nothing watched that sentence
568
+ - `check-doc-claims` reads docblocks, absent defaults and release status, not
569
+ prose counts. The count is gone rather than bumped: five would have left the same
570
+ trap armed for guard six, which is the failure the last bullet under "Module
571
+ discipline" already describes. The paragraph names its guards now.
572
+
573
+ Two things stated rather than discovered later. A value that is genuinely a
574
+ string but typed `any` fails this check, and `String(...)` is rejected by the
575
+ same guard, so the way out is a cast at the call site - a claim a reviewer can
576
+ see; the failure message says so. And the blind spot: a message assembled into a
577
+ local and then logged passes unexamined, because following an initializer means
578
+ deciding which declaration a name refers to, and being wrong about that is worse
579
+ than a limit written down.
580
+
581
+ ### Sizes
582
+
583
+ All three IIFE variants are **byte-identical** across the whole series
584
+ (40,944 / 27,891 / 29,655 raw, 12,094 / 8,177 / 8,673 brotli): `src/directives.ts`
585
+ ships in none of them. The whole cost of the cycle is **+310 B raw / +117 B brotli
586
+ on `dist/directives2.js`**, the opt-in directives subpath - the DEV version guard,
587
+ which a consumer's production build folds away, and the listener-options field.
588
+
589
+ Vue's own numbers moved, and the generated table now says so: the hand-wired
590
+ `createVaporApp` minimum 44.1 -> 44.5 KB minified, `vapor-chamber/vapor` over it
591
+ 4.8 -> 4.4, and `+ vaporInteropPlugin` 81.7 -> 84.2. Every vapor-chamber export row
592
+ is unchanged at brotli. The Vapor outlet guard reads its saving at 21.14 KB brotli
593
+ against the 20.42 recorded on rc.8, own machinery 4.21 against 4.23 - measured by
594
+ the committed guard on this tree, so the direction (rc.9 makes the vDOM-plus-interop
595
+ alternative more expensive, not less) is what the numbers support.
596
+
597
+ ### A tooling gap, measured and not ours - and now unreachable
598
+
599
+ `vue-tsc` resolves an IMPORTED directive against its `.d.ts` and type-checks the
600
+ template call passing the ARGUMENT as a raw string - the shape [#15490] replaced - so
601
+ `import { vcCommandVapor as vVc } from 'vapor-chamber/directives'` with
602
+ `v-vc:command` failed `vue-tsc --noEmit` against a correct `.d.ts` and a working
603
+ runtime. Isolated three ways: a directive declared LOCALLY in the SFC is not checked
604
+ at all and passes; the same directive imported fails at the argument slot; annotating
605
+ it as Vue's own `VaporDirective` fails too. Upstream's `dts-test` covers the RUNTIME
606
+ call shape only, and template checking lives in vuejs/language-tools, a different
607
+ repository, so the change was never checked against it.
608
+
609
+ **The reshape above closes it, and the mechanism is the point: `v-vc-command` has no
610
+ argument, so there is nothing for `vue-tsc` to mistype.** This is not the tool
611
+ catching up - the bug is still there, and any directive that takes an argument still
612
+ trips it. It is that the reshape removed a whole class of upstream exposure rather
613
+ than only the defect that started this cycle. If an argument-based notation is ever
614
+ proposed again, this is part of its cost.
615
+
616
+ So `examples/vapor-sfc` no longer registers the directive app-wide. That registration
617
+ was never a style choice - it was added with the example itself and existed only to
618
+ give `vue-tsc` no declaration to check, which is opting out of checking rather than
619
+ satisfying it. The example now imports the binding, the way the documentation
620
+ recommends, and its own `vue-tsc --noEmit` build step is the proof. `npm run
621
+ check:tsc-gap` and `scripts/check-vue-tsc-directive-gap.mjs` are deleted: a guard
622
+ that can only go red for a reason that cannot affect this package carries no
623
+ information about it.
624
+
625
+ ### Known limitations, measured this cycle and not fixed here
626
+
627
+ Neither is an rc.9 regression; both were found by measuring rc.9 against this
628
+ library and are recorded where a consumer looks rather than left in a log.
629
+
630
+ - **A bound update overwrites what `v-vc-command` writes by hand.** The directive
631
+ adds `vc-loading` / `vc-error` and sets `disabled` directly on the element. Vue
632
+ compares a binding against its own previous value, not against the live DOM, so
633
+ a hand-written value survives until that binding next moves and is then
634
+ overwritten wholesale. Measured on compiled templates: on an element that is NOT
635
+ the component root, a `:class` update drops `vc-loading` on BOTH renderers; on a
636
+ component root, where the class arrives as a fallthrough prop and Vue patches it
637
+ through `classList`, it survives. `:disabled` behaves the same way: a binding that
638
+ flips mid-dispatch leaves the button looking enabled and accepting clicks that do
639
+ nothing, since re-entrancy is blocked by internal state a binding cannot reach. rc.9's `a5f7a2c1` ("keep
640
+ transition classes when updating class binding") is upstream solving the same
641
+ class of problem for its OWN classes, via a list userland cannot join. Until
642
+ this is fixed, do not put `:class` or `:disabled` on the same element as
643
+ `v-vc-command` unless that element is a component root.
644
+ - **Composables imported from the package ROOT degrade in a production bundle.**
645
+ Known since v1.20.0 and now measured in a real production bundle rather than a
646
+ model of one: `loading` is not a Vue ref, a listener registered through
647
+ `useCommand().on()` outlives `app.unmount()`, and the KeepAlive guard is inert.
648
+ Stated precisely, because the obvious phrasing is wrong: the state still
649
+ ADVANCES - the reducer runs and `state.value` changes - it is simply not
650
+ reactive, so a template bound to it never re-renders. The remedy is unchanged
651
+ and is three-fold: import from `vapor-chamber/vue` or `vapor-chamber/vapor`, or
652
+ let `vaporChamberWire()` do it at build time. The production warning that names
653
+ those remedies fires in that bundle, which this cycle confirmed.
654
+
655
+ ### Added: `npm run gate`
656
+
657
+ One chain in the house order - typecheck, build, test:run, test:vapor, size:check,
658
+ lint, coverage, stamp check, docs, size:doc, clean tree - failing fast, one result
659
+ line per step, and a failure that names the step, so `git rebase --exec` identifies
660
+ both the commit and the step. `npm run docs` and `npm run size:doc` regenerate
661
+ files, so each is followed by a scoped `git diff --exit-code` and the chain ends by
662
+ asserting a clean tree: drift fails the gate instead of being repaired by it. The
663
+ rule it enforces had failed three times before this.
664
+
665
+ [#15490]: https://github.com/vuejs/core/pull/15490
666
+
667
+ ## v1.21.0 - 2026-09-16 `vapor-chamber/vitest`, a Vitest 5 plugin, and the suite rewritten on it
668
+
669
+ A testing entry for Vitest 5 users, built from Vitest's own mechanisms
670
+ (`expect.extend`, setup files, `test.extend`, `provide`, `configureVitest`)
671
+ and named in Vitest's vocabulary. The guide is
672
+ [docs/integrations/vitest.md](docs/integrations/vitest.md); every code block
673
+ in it runs in a test. No library module changed: outside the two new entries
674
+ and the Vite plugin file, `src/` is as v1.20.0 left it, and the IIFEs are
675
+ byte-identical.
676
+
677
+ ### Added: `vapor-chamber/vitest` - one setup-file line
678
+
679
+ `setupFiles: ['vapor-chamber/vitest']`, plus `"types": ["vapor-chamber/vitest"]`
680
+ in `tsconfig.json`, gives every test file:
681
+
682
+ - **The matchers**, in Vitest's spy vocabulary with Dispatched for Called:
683
+ `toHaveBeenDispatched`, `...With`, `...Times`, `...Once`,
684
+ `toHaveBeenNthDispatchedWith`, `toHaveBeenLastDispatchedWith`; and
685
+ `toHaveFailedWith(action, code)`, `toSucceedWith(value?)`, `toFailWith(code)`
686
+ for results. Headers use Vitest 5.0.1's wording, and a failure lists each
687
+ dispatch with Vitest's own diff, as `toHaveBeenCalledWith` lists calls.
688
+ Payloads and values compare as `toEqual` does: a first cut called bare
689
+ `equals`, which found two Maps with different entries equal. On a typed bus
690
+ the action, payload and result are type-checked.
691
+ - **A recorded shared bus.** Before each test `getCommandBus()` returns a new
692
+ `createCommandBus()` bus that `tap()` records: the real bus, not a double.
693
+ It is replaced before the next test rather than reset after this one, so a
694
+ failed test's bus keeps its record.
695
+ - **The library imported lazily**, inside the hook. Measured on this
696
+ repository's suite: importing it when the setup file loads failed 4 tests in
697
+ 2 files, which assert the one-shot Vue detection from a clean start, and the
698
+ lazy import failed none under full-suite load.
699
+ - **What a failed test dispatched.** A test failing on any assertion gets its
700
+ dispatches appended to the error, printed with Vitest's `chai.util.inspect`
701
+ in full. The error's stack gets the text too: the json reporter reads
702
+ `stack`, and showed nothing on a packed consumer until it did.
703
+ `context.annotate()` was not an option: on Vitest 5.0.1 it throws once the
704
+ test has failed.
705
+
706
+ ### Added: `bus` and `asyncBus` fixtures
707
+
708
+ `it` and `test` from `vapor-chamber/vitest` are Vitest's `test.extend('bus',
709
+ ...).extend('asyncBus', ...)`: `({ bus })` is a recorded `createCommandBus()`,
710
+ `({ asyncBus })` a recorded `createAsyncCommandBus()`, built only for a test
711
+ that asks and new in each. Vitest's own `it` is not changed. `bus` is not the
712
+ shared bus, and neither fixture is disposed after the test. Users build their
713
+ own snippets with `.extend` (`it.extend('shop', ({ bus }) => ...)`).
714
+
715
+ - **Module identity**, measured on a packed install: the fixture bus and the
716
+ test file's own `import 'vapor-chamber'` are one module instance in five
717
+ configurations (node, happy-dom, `server.deps.inline`, vmThreads, happy-dom
718
+ with inline) and with no setup file; a second installed copy does not
719
+ recognise the bus.
720
+ - **Limit, stated:** after `vi.resetModules()` a fixture loads a fresh module
721
+ instance, as the shared bus does, while the file's static imports keep the
722
+ first; a test mixing the two (`instanceof BusError`, `history()` state)
723
+ creates its bus from its own import. Found by migrating this suite: 18
724
+ converted tests in the 7 files that reset modules failed under a forced
725
+ reset before every test, and keep `createCommandBus()`.
726
+
727
+ ### Added: `vc` - `tap`, `stubGlobal`, `stubEnv`, `mcp`
728
+
729
+ As Vitest keeps its utilities in `vi`, the entry keeps its own in `vc`; each
730
+ member is also the named export.
731
+
732
+ - **`vc.tap(bus)`** records every dispatch through the bus's own `onAfter`
733
+ hook and returns the same bus. Idempotent; a sealed bus refuses it with
734
+ `VC_CORE_SEALED`.
735
+ - **`vc.stubGlobal(name, value)` / `vc.stubEnv(name, value)`** return a
736
+ disposable, so `using` restores the one name when the block ends, a throw
737
+ included - before `afterEach`, where `vi.stubGlobal` / `vi.stubEnv` restore at
738
+ the start of the next test. Stored as Vitest stores them; a stub made without
739
+ `using` is restored before the next test. The type declarations compile
740
+ without an `esnext.disposable` lib.
741
+ - **`vc.mcp(handler)`** drives an MCP server handler as an agent does:
742
+ `initialize()`, `tools()`, `toolNames()`, `call(name, args)`, `request()` and
743
+ `notify()`, ids numbered for you, a protocol error thrown. The matchers
744
+ `toBeToolResult(value?)` and `toBeToolError(text | RegExp?)` read tool
745
+ results. It takes the handler, not the bus, so any JSON-RPC MCP handler works.
746
+
747
+ ### Added: `vapor-chamber/vitest/pure`
748
+
749
+ The same helpers and `matchers`, with no side effect: no matcher registered, no
750
+ hook, no shared bus. It imports nothing from the library at runtime (type
751
+ imports only), asserted from the source and from `dist/`.
752
+
753
+ ### Added: coded diagnostics, `VcTestError`
754
+
755
+ Each carries `code`, `why`, `fix` and a `docs` link.
756
+
757
+ - **`VC_TEST_UNTAPPED`** - a bus matcher received a bus nobody tapped. It
758
+ throws for `.not` too, so a negated assertion cannot pass vacuously.
759
+ - **`VC_TEST_DUPLICATE_INSTANCE`** - the bus comes from a second installed copy
760
+ of the library; told apart from an untapped bus by its `vapor-chamber:*`
761
+ symbols.
762
+ - **`VC_TEST_TAP_REMOVED`** - `clear()` or `dispose()` removed the recording
763
+ hook. Raised only where a missed record could change the verdict, and by the
764
+ counting and positional matchers always; `tap(bus)` again resumes the same
765
+ record.
766
+ - **`VC_TEST_VITEST_MAJOR`** - a warning, never a failure, from the plugin, on
767
+ a Vitest major this release does not know.
768
+
769
+ ### Added: `vaporChamberTest()` in `vapor-chamber/vite`
770
+
771
+ The plugin adds what needs configuration; the setup-file line alone needs none
772
+ of it.
773
+
774
+ - **Setup file first**, once, keeping the user's (a string included, which
775
+ Vite's merge would have put ahead of ours).
776
+ - **`sharedBus.exclude`**: globs from the project root, handed to the setup
777
+ file through `provide`, for files that must start with nothing imported.
778
+ - **The island project**: `*.island.test.*` and `tests/islands/**` run in an
779
+ injected project with a DOM (`islands.environment`, default `happy-dom`;
780
+ `islands: false` injects nothing). Two Vitest 5 behaviours found by running
781
+ it, both handled and pinned: an inheriting project's `include` is EXTENDED by
782
+ Vite's merge (this suite ran every file twice, 348 file runs for 174), and the
783
+ injected project's `exclude` is the root project's array object (pushing into
784
+ it hid every island file). Its inherited `benchmark.include` is emptied too:
785
+ before that, `vitest bench` ran each bench file twice (this repository's CI
786
+ bench job 89 s -> 179 s).
787
+ - **A line on top of a run**, `\\// powered by vc-vitest-plugin`, in Vue's
788
+ green (#42B883) and slate (#35495E): 24-bit where `COLORTERM` says so, the
789
+ nearest xterm-256 shades otherwise, plain where colors are off (Vitest's
790
+ color rules: `NO_COLOR`, `FORCE_COLOR`, `CI`, `TERM`). Once per run, only
791
+ beside Vitest's own banner, on stderr: written to stdout it broke
792
+ `vitest list --json` (measured).
793
+ - **The public type stays structural**: `dist/vite-hmr.d.ts` names no vitest
794
+ module, so a `vapor-chamber/vite` user without Vitest is unaffected.
795
+
796
+ ### Added: `vc-vitest-mcp` - a minimal MCP server for the test run
797
+
798
+ `npx vc-vitest-mcp` in a project root serves three tools over stdio, from a
799
+ schema bus built on this library's own MCP layer (`createVitestMcp` in
800
+ `vapor-chamber/vitest/mcp` for programmatic use):
801
+
802
+ - **`runTests`** runs every test file, or those `target.files` selects, in a
803
+ Vitest that stays warm between calls, and returns counts and each failure
804
+ with its message. With `vapor-chamber/vitest` as the project's setup file, a
805
+ failure carries the dispatches the test made.
806
+ - **`getTestResults`** returns the last run with `stale` and the files changed
807
+ since. It waits for a run in progress: an MCP client sends calls
808
+ concurrently, and unqueued it answered "no run yet" to a `runTests` sent just
809
+ before it (found over stdio on a packed install).
810
+ - **`getCoverageGaps`** runs the tests once with coverage and lists, per source
811
+ file, the uncovered lines, functions and branch arms.
812
+
813
+ Measured on Vitest 5.0.1 before it was written: a warm instance re-ran a
814
+ stale module after a test file and a source file were edited, until each path
815
+ was passed to `vitest.invalidateFile()`, and ran no test file created after it
816
+ started until its specification cache was cleared. The server watches the
817
+ project and does both before each run. A coverage provider in a Vitest created
818
+ inside a Vitest test worker wrote no report, so coverage runs as a separate
819
+ `vitest run`, cold. v8 coverage lists only the source files the selected tests
820
+ loaded, unless the config sets `coverage.include`.
821
+
822
+ - **What an agent can choose:** only `target.files`, Vitest filters among the
823
+ test files the project's config includes. The root and config are fixed by
824
+ whoever starts the server. A filter starting with `-` is refused, because the
825
+ coverage run passes filters on a command line (`VC_TEST_MCP_INVALID_FILES`).
826
+ - **Coded:** `VC_TEST_MCP_INVALID_FILES`, `VC_TEST_MCP_NO_RUN`,
827
+ `VC_TEST_MCP_NO_COVERAGE`, in `VcTestError`'s catalogue.
828
+ - **The fixture:** `tests/vitest-mcp-server.test.ts` drives the tools through
829
+ the real `createMcpHandler` on a real Vitest and a temporary project (edits,
830
+ a new file, a load error, an unhandled rejection, parallel calls, refused
831
+ filters, a real coverage report); `tests/vitest-consumer.test.ts` starts the
832
+ command from a packed install over stdio in a project whose config prints and
833
+ whose test logs, and every stdout line parses as JSON-RPC. With Vitest's
834
+ output sent to stdout instead, that assertion fails.
835
+ - **Names:** camelCase, as a schema bus normalizes action names;
836
+ vitest-community/mcp's `run_tests` is `runTests` here.
837
+
838
+ ### Packaging
839
+
840
+ - Exports `./vitest`, `./vitest/pure` and `./vitest/mcp`, and the
841
+ `vc-vitest-mcp` command (`bin/`); `dist/vitest.js` joins `sideEffects`.
842
+ - `vitest >=5.0.0` is an optional peer dependency.
843
+ - `vapor-chamber` and `vitest` stay external in the build, so the entry shares
844
+ the app's copy of the library instead of bundling a second one.
845
+ - `dist/index.d.ts` reaches no vitest declaration (Vitest's types in the root
846
+ would fail a consumer without Vitest with TS2882, reproduced as the test's
847
+ control). Types compile strict, without `skipLibCheck`, on a packed install.
848
+ - Sizes (brotli, tooling only, never in an app bundle): `./vitest` 3.9 KB on
849
+ first load plus 22.9 KB loaded on demand (the library itself); `./vitest/pure`
850
+ 3.1 KB; `./vitest/mcp` 8.9 KB; `./vite` 1.9 -> 2.2 KB.
851
+
852
+ ### This repository's suite runs on it
853
+
854
+ On Vitest 5.0.1 with `restoreMocks`, `unstubGlobals` and `unstubEnvs` on
855
+ (`clearMocks: false` pinned through the 5.0 default flip), and on the plugin.
856
+ Migrated by verified TypeScript-AST scripts, each file run alone on Node 24 and
857
+ Node 22 with unchanged counts, shuffled runs matching the commit before:
858
+
859
+ - 53 manual cleanup calls removed, and 82 more restores turned into `using`;
860
+ - 70 `ok` / `value` assertion pairs to `toSucceedWith` / `toFailWith`, 73
861
+ `expect(r.ok).toBe(false)` to `toFailWith(code)` from a run recording each
862
+ site's actual code, and 6 more `toSucceedWith`;
863
+ - the MCP tests to `mcpClient` (`tests/mcp.test.ts` 452 -> 379 lines);
864
+ - 481 tests opening with `const bus = createCommandBus()` or
865
+ `createAsyncCommandBus()` to the fixtures (46 files, 445 lines fewer).
866
+
5
867
  ## v1.20.0 - 2026-09-15: Vue 3.6.0-rc.8 alignment
6
868
 
7
869
  ### `vaporChamberWire()` under the dev server: the redirect stays build-only, measured