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.
- package/CHANGELOG.md +862 -0
- package/README.md +49 -27
- package/ROADMAP.md +11 -9
- package/bin/vc-vitest-mcp.mjs +20 -0
- package/dist/LICENSE.txt +6 -2
- package/dist/chamber.d.ts.map +1 -1
- package/dist/chamber.js.map +1 -1
- package/dist/command-bus.d.ts +21 -1
- package/dist/command-bus.d.ts.map +1 -1
- package/dist/command-bus.js +5 -5
- package/dist/command-bus.js.map +1 -1
- package/dist/directives.d.ts +154 -14
- package/dist/directives.d.ts.map +1 -1
- package/dist/directives.js +2 -2
- package/dist/directives2.js +53 -20
- package/dist/directives2.js.map +1 -1
- package/dist/index.js +19 -63
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +2 -2
- package/dist/mcp.js.map +1 -1
- package/dist/plugins-core.d.ts.map +1 -1
- package/dist/plugins-extra.d.ts.map +1 -1
- package/dist/plugins-io.d.ts +73 -12
- package/dist/plugins-io.d.ts.map +1 -1
- package/dist/plugins-io.js +40 -67
- package/dist/plugins-io.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/settled.d.ts +57 -0
- package/dist/settled.d.ts.map +1 -1
- package/dist/settled.js +8 -0
- package/dist/settled.js.map +1 -0
- package/dist/ssr.d.ts.map +1 -1
- package/dist/ssr2.js +3 -3
- package/dist/ssr2.js.map +1 -1
- package/dist/testing.d.ts.map +1 -1
- package/dist/utilities.d.ts.map +1 -1
- package/dist/vapor-chamber-core.iife.js +18 -18
- package/dist/vapor-chamber-core.iife.js.map +1 -1
- package/dist/vapor-chamber-core.iife.min.js +1 -1
- package/dist/vapor-chamber-elements.iife.js +18 -18
- package/dist/vapor-chamber-elements.iife.js.map +1 -1
- package/dist/vapor-chamber-elements.iife.min.js +1 -1
- package/dist/vapor-chamber.iife.js +96 -84
- package/dist/vapor-chamber.iife.js.map +1 -1
- package/dist/vapor-chamber.iife.min.js +1 -1
- package/dist/vite-hmr.d.ts +53 -0
- package/dist/vite-hmr.d.ts.map +1 -1
- package/dist/vite-hmr.js +77 -1
- package/dist/vite-hmr.js.map +1 -1
- package/dist/vitest-mcp.d.ts +120 -0
- package/dist/vitest-mcp.d.ts.map +1 -0
- package/dist/vitest-mcp.js +213 -0
- package/dist/vitest-mcp.js.map +1 -0
- package/dist/vitest-pure.d.ts +344 -0
- package/dist/vitest-pure.d.ts.map +1 -0
- package/dist/vitest-pure.js +377 -0
- package/dist/vitest-pure.js.map +1 -0
- package/dist/vitest.d.ts +120 -0
- package/dist/vitest.d.ts.map +1 -0
- package/dist/vitest.js +18 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +38 -11
- package/scripts/ascii-table.mjs +137 -0
- package/scripts/bench-ratios-reporter.mjs +57 -2
- package/scripts/build.mjs +53 -2
- package/scripts/check-ascii.mjs +153 -58
- package/scripts/check-console-shape.mjs +431 -0
- package/scripts/check-env-guards.mjs +5 -1
- package/scripts/check-example-directive.mjs +181 -0
- package/scripts/check-size.mjs +16 -3
- package/scripts/gate.mjs +256 -0
- package/scripts/measure-size.mjs +4 -1
- package/src/chamber.ts +2 -8
- package/src/command-bus.ts +43 -20
- package/src/directives.ts +472 -66
- package/src/mcp.ts +1 -1
- package/src/plugins-core.ts +35 -42
- package/src/plugins-extra.ts +8 -11
- package/src/plugins-io.ts +119 -91
- package/src/schema.ts +14 -3
- package/src/settled.ts +73 -1
- package/src/ssr.ts +17 -3
- package/src/testing.ts +19 -22
- package/src/utilities.ts +3 -4
- package/src/vite-hmr.ts +168 -0
- package/src/vitest-mcp.ts +302 -0
- package/src/vitest-pure.ts +741 -0
- 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
|