vapor-chamber 1.24.0 → 1.26.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 (306) hide show
  1. package/CHANGELOG.md +1684 -0
  2. package/README.md +194 -102
  3. package/ROADMAP.md +15 -15
  4. package/dist/LICENSE.txt +12 -3
  5. package/dist/a11y.d.ts +29 -0
  6. package/dist/a11y.d.ts.map +1 -0
  7. package/dist/a11y.js +43 -0
  8. package/dist/a11y.js.map +1 -0
  9. package/dist/blade.js +6 -6
  10. package/dist/blade.js.map +1 -1
  11. package/dist/chamber-vapor.d.ts +18 -19
  12. package/dist/chamber-vapor.d.ts.map +1 -1
  13. package/dist/chamber-vapor.js +6 -5
  14. package/dist/chamber-vapor.js.map +1 -1
  15. package/dist/chamber.d.ts +27 -89
  16. package/dist/chamber.d.ts.map +1 -1
  17. package/dist/chamber.js +190 -167
  18. package/dist/chamber.js.map +1 -1
  19. package/dist/command-bus.d.ts +168 -151
  20. package/dist/command-bus.d.ts.map +1 -1
  21. package/dist/command-bus.js +423 -282
  22. package/dist/command-bus.js.map +1 -1
  23. package/dist/dict.d.ts +2 -2
  24. package/dist/dict.js.map +1 -1
  25. package/dist/directives.d.ts +50 -78
  26. package/dist/directives.d.ts.map +1 -1
  27. package/dist/directives.js +2 -2
  28. package/dist/directives2.js +72 -6
  29. package/dist/directives2.js.map +1 -1
  30. package/dist/failure.d.ts +89 -0
  31. package/dist/failure.d.ts.map +1 -0
  32. package/dist/failure.js +70 -0
  33. package/dist/failure.js.map +1 -0
  34. package/dist/fast-lane.d.ts +4 -3
  35. package/dist/fast-lane.d.ts.map +1 -1
  36. package/dist/fast-lane.js +29 -18
  37. package/dist/fast-lane.js.map +1 -1
  38. package/dist/form.d.ts +30 -1
  39. package/dist/form.d.ts.map +1 -1
  40. package/dist/freeze.js.map +1 -1
  41. package/dist/http-cache.d.ts +26 -10
  42. package/dist/http-cache.d.ts.map +1 -1
  43. package/dist/http-errors.d.ts +52 -20
  44. package/dist/http-errors.d.ts.map +1 -1
  45. package/dist/http.d.ts +60 -76
  46. package/dist/http.d.ts.map +1 -1
  47. package/dist/http.js +226 -104
  48. package/dist/http.js.map +1 -1
  49. package/dist/iife-core.d.ts +8 -10
  50. package/dist/iife-core.d.ts.map +1 -1
  51. package/dist/iife-core.js +13 -10
  52. package/dist/iife-core.js.map +1 -1
  53. package/dist/iife-elements.d.ts +15 -14
  54. package/dist/iife-elements.d.ts.map +1 -1
  55. package/dist/iife-elements.js +12 -9
  56. package/dist/iife-elements.js.map +1 -1
  57. package/dist/iife.d.ts +7 -9
  58. package/dist/iife.d.ts.map +1 -1
  59. package/dist/iife.js +14 -10
  60. package/dist/iife.js.map +1 -1
  61. package/dist/index.d.ts +14 -39
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +659 -189
  64. package/dist/index.js.map +1 -1
  65. package/dist/keys.js +11 -9
  66. package/dist/keys.js.map +1 -1
  67. package/dist/ledger.d.ts +33 -0
  68. package/dist/ledger.d.ts.map +1 -0
  69. package/dist/ledger.js +73 -0
  70. package/dist/ledger.js.map +1 -0
  71. package/dist/library-names.d.ts +15 -0
  72. package/dist/library-names.d.ts.map +1 -0
  73. package/dist/library-names.js +13 -0
  74. package/dist/library-names.js.map +1 -0
  75. package/dist/mcp.d.ts +5 -25
  76. package/dist/mcp.d.ts.map +1 -1
  77. package/dist/mcp.js +5 -7
  78. package/dist/mcp.js.map +1 -1
  79. package/dist/outbox.d.ts +34 -17
  80. package/dist/outbox.d.ts.map +1 -1
  81. package/dist/outbox.js +34 -16
  82. package/dist/outbox.js.map +1 -1
  83. package/dist/plugins-core.d.ts +9 -18
  84. package/dist/plugins-core.d.ts.map +1 -1
  85. package/dist/plugins-core.js +204 -0
  86. package/dist/plugins-core.js.map +1 -0
  87. package/dist/plugins-extra.d.ts +3 -1
  88. package/dist/plugins-extra.d.ts.map +1 -1
  89. package/dist/plugins-io.d.ts +7 -83
  90. package/dist/plugins-io.d.ts.map +1 -1
  91. package/dist/plugins-io.js +6 -289
  92. package/dist/plugins-io.js.map +1 -1
  93. package/dist/plugins-schema.d.ts +1 -1
  94. package/dist/plugins-schema.d.ts.map +1 -1
  95. package/dist/plugins-schema.js +8 -11
  96. package/dist/plugins-schema.js.map +1 -1
  97. package/dist/plugins.d.ts +2 -2
  98. package/dist/plugins.d.ts.map +1 -1
  99. package/dist/poll-with.d.ts +38 -0
  100. package/dist/poll-with.d.ts.map +1 -0
  101. package/dist/reactive.js +1 -1
  102. package/dist/reactive.js.map +1 -1
  103. package/dist/router/announce.d.ts +14 -0
  104. package/dist/router/announce.d.ts.map +1 -0
  105. package/dist/router/blade.d.ts.map +1 -1
  106. package/dist/router/composables.d.ts +2 -3
  107. package/dist/router/composables.d.ts.map +1 -1
  108. package/dist/router/dom.d.ts +5 -0
  109. package/dist/router/dom.d.ts.map +1 -1
  110. package/dist/router/engine.d.ts +13 -10
  111. package/dist/router/engine.d.ts.map +1 -1
  112. package/dist/router/errors.d.ts +25 -16
  113. package/dist/router/errors.d.ts.map +1 -1
  114. package/dist/router/index.d.ts +17 -3
  115. package/dist/router/index.d.ts.map +1 -1
  116. package/dist/router/index.js +2 -2
  117. package/dist/router/loaders.d.ts +12 -3
  118. package/dist/router/loaders.d.ts.map +1 -1
  119. package/dist/router/menu.d.ts +7 -2
  120. package/dist/router/menu.d.ts.map +1 -1
  121. package/dist/router/remote.d.ts +8 -13
  122. package/dist/router/remote.d.ts.map +1 -1
  123. package/dist/router/remote.js +1 -1
  124. package/dist/router/remote.js.map +1 -1
  125. package/dist/router/revalidate.d.ts +9 -16
  126. package/dist/router/revalidate.d.ts.map +1 -1
  127. package/dist/router/table.d.ts +6 -4
  128. package/dist/router/table.d.ts.map +1 -1
  129. package/dist/router/types.d.ts +7 -3
  130. package/dist/router/types.d.ts.map +1 -1
  131. package/dist/router/url.d.ts.map +1 -1
  132. package/dist/router/vapor.d.ts +7 -9
  133. package/dist/router/vapor.d.ts.map +1 -1
  134. package/dist/router/vapor.js +11 -8
  135. package/dist/router/vapor.js.map +1 -1
  136. package/dist/router/vdom.js +1 -1
  137. package/dist/router/vdom.js.map +1 -1
  138. package/dist/router-fetch/index.d.ts.map +1 -1
  139. package/dist/router-fetch/index.js +3 -2
  140. package/dist/router-fetch/index.js.map +1 -1
  141. package/dist/router.js +239 -111
  142. package/dist/router.js.map +1 -1
  143. package/dist/scheduler.d.ts +31 -0
  144. package/dist/scheduler.d.ts.map +1 -0
  145. package/dist/schema.d.ts +31 -12
  146. package/dist/schema.d.ts.map +1 -1
  147. package/dist/settled.d.ts +28 -55
  148. package/dist/settled.d.ts.map +1 -1
  149. package/dist/settled.js +24 -1
  150. package/dist/settled.js.map +1 -1
  151. package/dist/shared-bus.d.ts +46 -0
  152. package/dist/shared-bus.d.ts.map +1 -0
  153. package/dist/shared-bus.js +24 -0
  154. package/dist/shared-bus.js.map +1 -0
  155. package/dist/signal.d.ts.map +1 -1
  156. package/dist/signal.js +3 -5
  157. package/dist/signal.js.map +1 -1
  158. package/dist/ssr.d.ts +9 -41
  159. package/dist/ssr.d.ts.map +1 -1
  160. package/dist/ssr2.js +6 -7
  161. package/dist/ssr2.js.map +1 -1
  162. package/dist/store/core.d.ts +4 -0
  163. package/dist/store/core.d.ts.map +1 -0
  164. package/dist/store/core.js +34 -0
  165. package/dist/store/core.js.map +1 -0
  166. package/dist/store-base.d.ts +104 -0
  167. package/dist/store-base.d.ts.map +1 -0
  168. package/dist/store-base.js +217 -0
  169. package/dist/store-base.js.map +1 -0
  170. package/dist/store-core.d.ts +4 -0
  171. package/dist/store-core.d.ts.map +1 -0
  172. package/dist/store.d.ts +22 -55
  173. package/dist/store.d.ts.map +1 -1
  174. package/dist/store.js +39 -77
  175. package/dist/store.js.map +1 -1
  176. package/dist/stream-parser.d.ts +2 -4
  177. package/dist/stream-parser.d.ts.map +1 -1
  178. package/dist/stream-parser.js +2 -4
  179. package/dist/stream-parser.js.map +1 -1
  180. package/dist/testing.d.ts +14 -5
  181. package/dist/testing.d.ts.map +1 -1
  182. package/dist/transitions/vapor.d.ts +102 -0
  183. package/dist/transitions/vapor.d.ts.map +1 -0
  184. package/dist/transitions/vapor.js +107 -0
  185. package/dist/transitions/vapor.js.map +1 -0
  186. package/dist/transitions.d.ts +21 -36
  187. package/dist/transitions.d.ts.map +1 -1
  188. package/dist/transitions2.js +2 -1
  189. package/dist/transitions2.js.map +1 -1
  190. package/dist/transports.d.ts +42 -75
  191. package/dist/transports.d.ts.map +1 -1
  192. package/dist/transports.js +1 -420
  193. package/dist/transports2.js +418 -0
  194. package/dist/transports2.js.map +1 -0
  195. package/dist/utilities.d.ts +1 -1
  196. package/dist/utilities.d.ts.map +1 -1
  197. package/dist/vapor-chamber-core.iife.js +1070 -958
  198. package/dist/vapor-chamber-core.iife.js.map +1 -1
  199. package/dist/vapor-chamber-core.iife.min.js +1 -1
  200. package/dist/vapor-chamber-elements.iife.js +1133 -1018
  201. package/dist/vapor-chamber-elements.iife.js.map +1 -1
  202. package/dist/vapor-chamber-elements.iife.min.js +1 -1
  203. package/dist/vapor-chamber.iife.js +1609 -1282
  204. package/dist/vapor-chamber.iife.js.map +1 -1
  205. package/dist/vapor-chamber.iife.min.js +1 -1
  206. package/dist/vapor.d.ts +18 -4
  207. package/dist/vapor.d.ts.map +1 -1
  208. package/dist/vapor.js +6 -3
  209. package/dist/vapor.js.map +1 -1
  210. package/dist/vite-hmr.d.ts +63 -93
  211. package/dist/vite-hmr.d.ts.map +1 -1
  212. package/dist/vite-hmr.js +17 -11
  213. package/dist/vite-hmr.js.map +1 -1
  214. package/dist/vitest-mcp.js +3 -3
  215. package/dist/vitest-mcp.js.map +1 -1
  216. package/dist/vitest-pure.d.ts +10 -20
  217. package/dist/vitest-pure.d.ts.map +1 -1
  218. package/dist/vitest-pure.js +11 -21
  219. package/dist/vitest-pure.js.map +1 -1
  220. package/dist/vitest.d.ts.map +1 -1
  221. package/dist/vitest.js +1 -0
  222. package/dist/vitest.js.map +1 -1
  223. package/dist/vue.d.ts +9 -18
  224. package/dist/vue.d.ts.map +1 -1
  225. package/dist/vue.js +1 -1
  226. package/dist/vue.js.map +1 -1
  227. package/package.json +28 -8
  228. package/scripts/bench-ratios-reporter.mjs +130 -91
  229. package/scripts/browser-channel.mjs +34 -0
  230. package/scripts/build.mjs +19 -13
  231. package/scripts/check-ascii.mjs +9 -18
  232. package/scripts/check-example-directive.mjs +7 -6
  233. package/scripts/check-size.mjs +204 -3
  234. package/scripts/gate.mjs +7 -9
  235. package/scripts/generate-errors-doc.mjs +60 -0
  236. package/scripts/measure-size.mjs +14 -22
  237. package/scripts/serve-docs.mjs +4 -5
  238. package/scripts/stamp-docs.mjs +19 -19
  239. package/scripts/test-counts-reporter.mjs +3 -2
  240. package/src/a11y.ts +67 -0
  241. package/src/chamber-vapor.ts +27 -24
  242. package/src/chamber.ts +428 -408
  243. package/src/command-bus.ts +706 -563
  244. package/src/dict.ts +2 -2
  245. package/src/directives.ts +206 -157
  246. package/src/failure.ts +178 -0
  247. package/src/fast-lane.ts +56 -47
  248. package/src/form.ts +131 -13
  249. package/src/http-cache.ts +73 -32
  250. package/src/http-errors.ts +69 -27
  251. package/src/http.ts +282 -206
  252. package/src/iife-core.ts +13 -18
  253. package/src/iife-elements.ts +20 -22
  254. package/src/iife.ts +11 -16
  255. package/src/index.ts +50 -52
  256. package/src/ledger.ts +141 -0
  257. package/src/library-names.ts +22 -0
  258. package/src/mcp.ts +12 -45
  259. package/src/outbox.ts +92 -45
  260. package/src/plugins-core.ts +88 -171
  261. package/src/plugins-extra.ts +46 -76
  262. package/src/plugins-io.ts +26 -231
  263. package/src/plugins-schema.ts +10 -12
  264. package/src/plugins.ts +1 -3
  265. package/src/poll-with.ts +94 -0
  266. package/src/router/announce.ts +88 -0
  267. package/src/router/blade.ts +11 -17
  268. package/src/router/composables.ts +26 -24
  269. package/src/router/dom.ts +40 -30
  270. package/src/router/engine.ts +178 -107
  271. package/src/router/errors.ts +67 -62
  272. package/src/router/index.ts +74 -52
  273. package/src/router/loaders.ts +21 -13
  274. package/src/router/menu.ts +10 -5
  275. package/src/router/outlet.ts +3 -3
  276. package/src/router/remote.ts +8 -13
  277. package/src/router/revalidate.ts +39 -41
  278. package/src/router/table.ts +48 -31
  279. package/src/router/types.ts +7 -3
  280. package/src/router/url.ts +9 -12
  281. package/src/router/vapor.ts +54 -32
  282. package/src/router-fetch/index.ts +6 -2
  283. package/src/scheduler.ts +70 -0
  284. package/src/schema.ts +142 -70
  285. package/src/settled.ts +64 -59
  286. package/src/shared-bus.ts +70 -0
  287. package/src/signal.ts +7 -10
  288. package/src/ssr.ts +25 -67
  289. package/src/store/core.ts +45 -0
  290. package/src/store-base.ts +427 -0
  291. package/src/store.ts +46 -195
  292. package/src/stream-parser.ts +6 -10
  293. package/src/testing.ts +77 -58
  294. package/src/transitions/vapor.ts +210 -0
  295. package/src/transitions.ts +34 -64
  296. package/src/transports.ts +207 -320
  297. package/src/utilities.ts +10 -15
  298. package/src/vapor.ts +36 -5
  299. package/src/vite-hmr.ts +101 -133
  300. package/src/vitest-mcp.ts +4 -4
  301. package/src/vitest-pure.ts +27 -37
  302. package/src/vitest.ts +9 -2
  303. package/src/vue.ts +10 -19
  304. package/dist/http-errors.js +0 -11
  305. package/dist/http-errors.js.map +0 -1
  306. package/dist/transports.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,1690 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## v1.26.0 - 2026-10-04
6
+
7
+ ### Fixed (the 1.26 evaluation's bug list)
8
+
9
+ Each fix has a test that failed before it; the record is
10
+ `docs/rc-alignment-log.md`, section 35.42.
11
+
12
+ - **esbuild and webpack shipped all of Vue to every app using the
13
+ composables.** The root finds Vue at runtime through `import('vue')`, which
14
+ both bundlers resolve to the whole namespace, even in an app wired through
15
+ `vapor-chamber/vue`. That lookup now sits behind a build flag,
16
+ `__VC_WIRED_BUILD__`: `vaporChamberWire()` defines it in a Vite build, and an
17
+ esbuild or webpack app that imports its composables from `vapor-chamber/vue`
18
+ (or `/vapor`) adds `define: { __VC_WIRED_BUILD__: 'true' }`. With it the
19
+ lookup leaves the bundle, and so does webpack's "Critical dependency"
20
+ warning. Without it nothing changes, which keeps a root-only Vue app working
21
+ on the Vite dev server, esbuild and webpack. `tests/root-probe-builds.test.ts`.
22
+ - **`getCommandBus()` no longer loads Vue in a Vue-less app.** The shared bus
23
+ (`getCommandBus`, `setCommandBus`, `resetCommandBus`) moved to a module
24
+ without the lookup; every import path is unchanged.
25
+ - **`router.destroy()` cancels the navigation in flight.** A navigation held
26
+ on a lazy component or a guard used to commit after the router was
27
+ destroyed, writing the history; it now answers `cancelled`, `isLoading`
28
+ returns to false, and a cancelled Back navigation walks no history.
29
+ `tests/router/destroy-cancels-navigation.test.ts`.
30
+ - **The HTTP client's cache respects writes and invalidations.** A read on the
31
+ wire across `invalidateCache()` or `clearCache()` is returned but not
32
+ stored, and a later read fetches instead of joining it; a successful
33
+ `post` / `put` / `patch` / `delete` drops the cached entry for its URL.
34
+ `tests/http-cache-writes.test.ts`.
35
+ - **`revalidateRoutes` re-reads past the loader cache.** With
36
+ `fetchLoaders({ cache })` a refresh after a command put the pre-command
37
+ data back on the page. The loader context gains `refresh` (true for a
38
+ refresh, false on a navigation), and `fetchLoaders` reads past its cache
39
+ then. `tests/router-fetch/revalidate-past-cache.test.ts`.
40
+ - **`useSharedCommandState().isLoading` across a route change, and on a
41
+ sealed bus.** A key in flight when the last holder left read false for the
42
+ next one; it now reads lit until the command settles. On a sealed bus,
43
+ `isLoading()` threw `core:refused:bus` whenever tracking had to start after
44
+ `seal()`; it now works there, and the bus stays sealed.
45
+ `tests/shared-state-handover.test.ts`.
46
+ - **`Retry-After` is read by RFC 9110's grammar**: digits, or an HTTP-date.
47
+ `-5`, `1.5`, `1e1`, `0x10` and `+3` were read as waits (-5,000 ms for
48
+ `-5`); they are ignored now. `tests/retry-after-grammar.test.ts`.
49
+ - **`persist()` without `getState` throws a `TypeError` at setup.** It used to
50
+ build a plugin that failed every save and stored nothing.
51
+ `tests/persist-requires-getstate.test.ts`.
52
+
53
+ The IIFE size budgets were raised to the measured sizes (owner, 2026-10-01);
54
+ the per-fix ledger is in `scripts/check-size.mjs`.
55
+
56
+ ### Faster, and two more fixes (the perf-1.26 audit)
57
+
58
+ Every speed figure is a process-replicated A/B of the built `dist/` (10
59
+ processes per comparison, a per-function A/A gate), measured with
60
+ `npm run ab` at two call lengths and recorded in `docs/rc-alignment-log.md`,
61
+ sections 35.44 and 35.53; the method is `docs/V8-RULES.md`, rules 12 to 18.
62
+ Against v1.25.0 no measured path is slower.
63
+
64
+ - **`useCommand().dispatch` and `useCommandQuery().query` allocate no
65
+ closure per call**: 0.915-0.934x, 2.7-3.9 ns less per dispatch.
66
+ - **A scoped plugin's `actions` filter allocates nothing per dispatch**
67
+ (`cache`, `circuitBreaker`, `rateLimit`, `metrics`, `serialize`,
68
+ `idempotent`, `supersede`, the outbox): 0.979-0.986x on a chain of four
69
+ scoped plugins and a listener, 4.6-6.6 ns.
70
+ - **Per-key `isLoading` tracking is about a quarter cheaper per dispatch**
71
+ (about 192 -> 146 ns): a key gets its reactive flag only when `isLoading()`
72
+ reads it (0.969-0.982x, 3.6-6.1 ns), a dispatch is matched to its settle
73
+ through a `Map` (0.858-0.871x, 24-26 ns), and the keys are a map by action,
74
+ then by target, so a dispatch builds no key string (0.892-0.905x, 15-18 ns;
75
+ log section 35.60).
76
+ - **Fixed: `isLoading` keys could collide across the `:`.** The action
77
+ `'a:b'` with target `'c'` and the action `'a'` with target `'b:c'` shared a
78
+ flag. They no longer do; every other key equality is unchanged (`1` and
79
+ `'1'` still share one, as in `commandKey`). `tests/isloading-keys.test.ts`.
80
+ - **A build profile, and `isLoading` keeps idle slots for reuse in it.**
81
+ `vaporChamberWire({ profile: 'lean' })` trades speed for memory; the
82
+ default, `'performance'`, trades memory for speed. It is ONE build-time
83
+ switch for every such trade, and it costs nothing per call: the plugin
84
+ defines `__VC_LEAN__`, so the choice folds out of the build. **The plugin's
85
+ output changes for every user:** with no option it now also defines
86
+ `__VC_LEAN__: 'false'`, in a build and under the dev server, unless the
87
+ app's config defines `__VC_LEAN__` itself (an esbuild or webpack app can
88
+ define it with one line). The IIFE builds are performance. The first trade:
89
+ in performance, a key nobody reads keeps its slot when it settles while its
90
+ action holds 256 or fewer, so its next dispatch allocates nothing
91
+ (0.848-0.865x, 21-22 ns per tracked dispatch, log section 35.61); lean drops
92
+ it at once, as before. `tests/isloading-profile.test.ts`.
93
+ - **A tracked dispatch on a sync bus is faster again: 0.645x, 44 ns less.**
94
+ `isLoading` matched each dispatch's start to its settle through a Map keyed
95
+ by the command; on a sync bus, where commands settle innermost first, it is
96
+ a stack now, still matched by identity, so a settle with no start (a query,
97
+ an emit) takes nothing. In every profile (log section 35.66). The async bus
98
+ keeps the Map. `tests/isloading-nesting.test.ts`.
99
+ - **Composable creation is back to v1.25.0's speed.** The build-flag check
100
+ added for the Vue lookup above ran on every creation, 7-9% of it, in any
101
+ build that does not define the flag; a cheap test now comes first.
102
+ - **Fixed: `useSharedCommandState().dispose()` called twice by one holder**
103
+ (by hand, then by its scope) released the shared state under the next
104
+ holder, whose `isLoading()` then never lit; before that change it could
105
+ split the state in two. Each holder now counts out once.
106
+ `tests/shared-state-dispose-twice.test.ts`.
107
+ - **Fixed: the HTTP cache dropped more than it had to.** An invalidation of
108
+ one URL, and every successful write, kept every cacheable read then in
109
+ flight from being stored, whatever its URL. Only a read of the invalidated
110
+ URL is kept from being stored now. `tests/http-cache-unrelated-invalidation.test.ts`.
111
+
112
+ The full IIFE budget was raised to the measured size again (owner: speed over
113
+ size); the ledger is in `scripts/check-size.mjs`.
114
+
115
+ ### Fixed: dispatch depth, and listeners changed during a dispatch
116
+
117
+ Each fix has a test that failed before it; the record is
118
+ `docs/rc-alignment-log.md`, section 35.67.
119
+
120
+ - **An async bus refused concurrent dispatches as if they were nested.** Its
121
+ depth counted dispatches in flight, so the 17th of 17 parallel, unnested
122
+ dispatches failed with `core:exceeded:depth`. Depth is now nesting, on both
123
+ buses: a dispatch is its parent's depth + 1, where the parent is the
124
+ dispatch whose handler, hook or listener started it. This also stops three
125
+ loops the old counter let run forever or overflow: a listener or after-hook
126
+ that re-dispatches its own action, and an async query that queries itself.
127
+ A loop through your own `await` is not followed.
128
+ - **A sync `query()` that queried itself overflowed the stack.** Queries now
129
+ count on the same depth as dispatches and stop at 16. A query costs about
130
+ 1.5 ns more for it, as a dispatch already does.
131
+ - **A listener could be skipped, or run twice, when listeners changed during
132
+ a dispatch.** One that removed an earlier peer and added one skipped the
133
+ next; one that removed itself and a later peer re-ran the one before it.
134
+ A dispatch, `emit`, the fast lane's `'live'` emit and `createTestBus()` now
135
+ call the listeners that existed when they started: one removed during it
136
+ does not run, one added during it runs from the next (the rule of DOM's
137
+ `EventTarget`). **This changes one behaviour:** a listener added during a
138
+ dispatch used to run in that same dispatch. Listener fan-out measured
139
+ 0.964-0.974x.
140
+
141
+ The IIFE budgets and the Blade-consumer ceiling were raised to the measured
142
+ sizes (owner: correctness first); the per-fix ledger is in
143
+ `scripts/check-size.mjs` and `tests/esm-treeshake.test.ts`.
144
+
145
+ ### Fixed: a deduped GET obeyed another caller's signal
146
+
147
+ A GET joining one already in flight for the same URL (the client's `dedupe`,
148
+ on by default) shared the first caller's request with its abort: the first
149
+ caller's `signal` rejected every caller, and a joining caller's own `signal`
150
+ was ignored. With `supersede` and a handler passing `cmd.signal` to
151
+ `http.get()`, the newest dispatch joined the request the plugin had just
152
+ aborted and failed as well. Each caller's signal now rejects its own promise
153
+ only; the fetch is cancelled once every caller holding it has aborted (a
154
+ caller without a signal holds it until it lands), and a GET made after that
155
+ starts a fresh request. A GET with no signal costs what it cost.
156
+ `dist/http.js` +1,407 B raw / +266 brotli, in no IIFE.
157
+ `tests/http-dedupe-signal.test.ts`; log section 35.46.
158
+
159
+ ### Fixed: a store's `$reset()` was seen by no plugin
160
+
161
+ `$reset()` wrote the store's state directly, so `persist` never saved it and
162
+ the next reload brought back the state from before the reset; `history`, the
163
+ devtools and every listener missed it too. It now dispatches `<id>$reset`
164
+ (`cart$reset`), registered beside the store's actions, and returns the
165
+ dispatch result. Changed with it: on an async bus the reset lands when the
166
+ returned promise settles, not at the call; a plugin can refuse it;
167
+ `inspectBus().actions` lists it. Action names containing `$` are reserved for
168
+ the library, and a bus's `naming` rule no longer checks them (the check runs
169
+ only once a name has failed the pattern). `dist/store.js` +147 B raw / +36
170
+ brotli; the IIFEs +17 raw and +3 to +15 brotli, from the naming check.
171
+ `tests/store-reset-command.test.ts`; log section 35.70. `$reset()` goes back
172
+ to what `state()` returns, so a `state()` that reads storage resets to the
173
+ saved record; `docs/store.md` shows loading it through an action instead (log
174
+ section 35.72).
175
+
176
+ ### Added: a development warning when the shared bus is replaced after use
177
+
178
+ `getCommandBus()` still creates a bus when none was set, the normal path of
179
+ a sync app. `setCommandBus()` replacing that bus after it was handed out now
180
+ warns, once, in development: code that already called `getCommandBus()`
181
+ keeps the old bus, so dispatches and listeners split between two buses with
182
+ no error. Setting the same bus again (the HMR shim) or setting after
183
+ `resetCommandBus()` stays quiet, and the `vapor-chamber/vitest` setup file
184
+ now resets before it installs each test's bus. No IIFE byte changes (the
185
+ warning folds out); `dist/shared-bus.js` +602 B raw / +202 brotli before a
186
+ consumer's production build drops it. `tests/shared-bus-fallback-warning.test.ts`;
187
+ log section 35.73.
188
+
189
+ ### Added: `ok`, `err` and `countOption` for app plugins
190
+
191
+ A plugin that answers without calling `next()` had to build its own
192
+ `{ ok: false, error }`, a different hidden class from the bus's results, so
193
+ every `result.ok` site that saw both went polymorphic. The root now exports
194
+ the bus's own two factories as `ok(value)` and `err(error)` (the same
195
+ functions, not copies), and a refusal reads `err(fail(code, message))`, with
196
+ `fail`, the plugin's third argument, minting the owner from its `id`. It also
197
+ exports `countOption(value, fallback, min?, max?)`, the library's rule for a
198
+ numeric option: NaN falls back to the default, negatives clamp, fractions
199
+ truncate, `Infinity` passes. `examples/custom-plugins.ts` uses them. No IIFE
200
+ byte changes; `dist/index.js` +47 B raw / +18 brotli.
201
+ `tests/plugin-result-factories.test.ts`; log section 35.74.
202
+
203
+ ### Fixed: two failures built a result of their own shape
204
+
205
+ `schemaValidator`'s rejection and `rehydrate()`'s refusal of an async bus
206
+ returned `{ ok: false, error }`, two fields, a different hidden class from
207
+ every other result, so a `result.ok` site that saw them went polymorphic.
208
+ Both now come from the bus's own factory (same error, same message; the
209
+ result gains `value: undefined` like every other failure). The check that
210
+ guards this read one line at a time and missed a literal written over
211
+ several lines; it now reads both (it found these two and a third,
212
+ `validateSchemas`, whose three fields already matched). No IIFE byte changes;
213
+ `dist/ssr2.js` -21 B brotli, `dist/plugins-schema.js` -4.
214
+ `tests/v8-shapes.test.ts`; log section 35.76.
215
+
216
+ ### Changed: every built-in plugin names itself in its failures
217
+
218
+ A built-in plugin that threw was reported as `plugin:failed:plugin`, which
219
+ does not say which one. Every bus-plugin factory now declares its id, the
220
+ owner of the failures it reports: a throw reads `serialize:failed:plugin`,
221
+ `logger:failed:plugin` and so on. The three bridges are `transport`, like
222
+ their other failures (`transport:lost:reply`), and both schema validators
223
+ `validateSchemas`. A plugin of yours without an `id` still reads
224
+ `plugin:failed:plugin`. Refusal codes do not change. `getErrorEntry()` and
225
+ `isRetryableCode()` answer for any `<id>:failed:plugin` with the
226
+ `plugin:failed:plugin` entry (before, a named plugin's throw had none).
227
+ `tests/plugin-ids.test.ts` holds the 25 ids; log section 35.77.
228
+
229
+ ### Changed: four failures that carried no code now do
230
+
231
+ Four failures the library builds were a plain `Error` with no code, so they
232
+ read as condition `failed`, a bug: `schemaValidator`'s rejection is now
233
+ `schemaValidator:invalid:payload`, `rehydrate()` given an async bus
234
+ `ssr:invalid:bus`, `synthesize()` without an adapter
235
+ `schema:missing:adapter`, and a `v-vc-command` dispatch that outlives its
236
+ timeout `directive:timeout:dispatch` (a transient condition). Messages are
237
+ unchanged, and each code is in `ERROR_CODE_REGISTRY`, as is
238
+ `throttle:limited:handler`, which `throttle()` raised but the registry
239
+ lacked. An outbox `isRetryable` rule can now tell a schema rejection
240
+ (`invalid`) from a bug. No IIFE byte changes. `tests/errors-match-catalogue.test.ts`
241
+ (which also fails on any new uncoded failure); log section 35.78.
242
+
243
+ ### Changed: two new conditions, `unauthenticated` and `conflict`
244
+
245
+ Two distinctions gRPC's canonical codes draw were hidden in our vocabulary.
246
+ A 401 or 419 (an expired session or CSRF token) now reads `unauthenticated`
247
+ instead of `refused`, which keeps 403 alone; a 409 or 412 (the target's state
248
+ is not the one the request assumed) reads `conflict` instead of `already`. **A
249
+ backend failure's code changes with it** (`remote:already:in_progress` is now
250
+ `remote:conflict:in_progress`, `remote:refused:...` from a 401 is
251
+ `remote:unauthenticated:...`): a rule that matches those strings must follow.
252
+ Behaviour is the same: both are final for the bus's retry; the outbox still
253
+ drops a 409/412 (a verdict) and keeps a 401/419 (replayed after sign-in),
254
+ now read from the condition instead of a status check (`dist/outbox.js` -36 B
255
+ brotli). The conditions read 1:1 against gRPC's codes in
256
+ `docs/plan-failures-and-contract.md` 4.2. IIFEs +27 B raw, brotli +11 core,
257
+ -2 full and elements. Log section 35.79.
258
+
259
+ ### Changed: a failure on the wire carries an RFC 9457 `type`
260
+
261
+ A failure serialized as a problem (`toJSON()`, what crosses a worker, a
262
+ channel, storage or a server) left `type` implicit, `about:blank`, which RFC
263
+ 9457 reads as "nothing beyond the HTTP status"; and an RFC client must ignore
264
+ extensions it does not know, so it dropped `code`, the only identity. `type`
265
+ is now the URI of the failure's condition,
266
+ `https://github.com/lucianofedericopereira/vapor-chamber/blob/main/docs/errors.md#<condition>`,
267
+ which resolves for every failure, the library's, your plugins' and your
268
+ backend's alike. `code` stays the exact identity; a backend's own `type` is
269
+ kept. `docs/errors.md` is new and generated from `ERROR_CODE_REGISTRY` by
270
+ `npm run docs`: one section per condition, its meaning, its retry default,
271
+ its gRPC reading and the codes under it. IIFEs +75 to +80 B brotli; the
272
+ Blade bundle +93. `tests/problem-type.test.ts`; log section 35.80.
273
+
274
+ ### Faster: a sync dispatch with listeners and no plugin
275
+
276
+ A bus with listeners or hooks but no plugin called its handler through the
277
+ empty plugin runner, two closures a dispatch. It now calls it directly, as the
278
+ bare path does. With `useSharedCommandState` and `isLoading`: 235 -> 207 ns a
279
+ dispatch. Log 35.143.
280
+
281
+ ### Changed (breaking): a plugin declares its `actions`; the bus skips it elsewhere
282
+
283
+ Every scoped plugin (`cache`, `circuitBreaker`, `rateLimit`, `metrics`,
284
+ `serialize`, `idempotent`, `supersede`, `debounce`, `throttle`,
285
+ `optimisticUndo`, the outbox, the bridges, `pollWith`) declares `actions`, and
286
+ the bus runs it only on those, one chain per action: a plugin that does not
287
+ match costs nothing (four that miss: 232 -> 114 ns a dispatch). A plugin of
288
+ your own declares `actions` the same way. `debounce`, `throttle` and
289
+ `optimisticUndo` take patterns now, as the others. A plugin called outside a
290
+ bus no longer filters. `tests/plugin-manifest.test.ts`; log 35.141.
291
+
292
+ ### Added: `pollWith`, a 202 Accepted followed to its end
293
+
294
+ `bus.use(pollWith({ bus }))`: a command answered 202 with a `Location`
295
+ resolves at once; `pollWith` polls the status monitor (each `Retry-After`) and
296
+ emits `<action>$done` with `{ command, result }`. The reference controller
297
+ answers 202 for an action returning `['accepted' => ...]`.
298
+ `tests/poll-with.test.ts`; log 35.140.
299
+
300
+ ### Added: `meta.request`, headers a plugin adds to the request
301
+
302
+ A bus plugin sets `cmd.meta.request = { headers }` (names lowercase) and the
303
+ HTTP bridges send them: a tracing plugin writes a W3C `traceparent` with
304
+ `bus.use()`, no bridge option. A batch merges its commands' headers in queue
305
+ order. `tests/meta-request.test.ts`; log 35.139.
306
+
307
+ ### Changed (breaking): one command envelope on every wire
308
+
309
+ `{ id?, command, target, payload?, meta? }` on a single request, a batch item
310
+ and a WebSocket frame: `id` where answers are multiplexed, `meta` with
311
+ `idempotencyKey`, `correlationId` and `causationId` when set. A backend reads
312
+ the key from `meta` (the batch's top-level `idempotencyKey` is gone); a single
313
+ request still sends the `Idempotency-Key` header. The WebSocket bridge now
314
+ sends the key. `tests/wire-envelope.test.ts`; log 35.138.
315
+
316
+ ### Removed (breaking): `CommandsOf` and the `window.__VUE__` fallback
317
+
318
+ `CommandsOf` was a second name for `InferMap`; use `InferMap`. A Vue namespace
319
+ is read from `window.__VAPOR_CHAMBER_VUE__` (or `configureVue()`), no longer
320
+ from `window.__VUE__`, which is Vue's key. Log 35.137.
321
+
322
+ ### Added: `meta.response`, the response a bridged command's reply arrived in
323
+
324
+ The HTTP bridges kept a reply's status, headers, `url` and `redirected` only on
325
+ failure. Now `cmd.meta.response` holds them for the 2xx a reply arrived in
326
+ (Fetch's member names, header names lowercase); `undefined` for a local
327
+ handler; a non-2xx answer stays the error's. A 202 is readable as accepted,
328
+ not finished (RFC 9110 15.3.3). `tests/meta-response.test.ts`; log 35.136.
329
+
330
+ ### Changed (breaking): the HTTP client's failures are the core's `BusError`
331
+
332
+ The client and the bridges give the same failure for the same answer, retried
333
+ by the async bus's rule (`retryClass`). `HttpError` is removed.
334
+
335
+ | Before | Now |
336
+ | --- | --- |
337
+ | `HttpError` for a non-2xx | `remote:<condition of the status>:<code>` (`http` when the body has no code) |
338
+ | `error.response.status`, `error.response.data` | `error.context.status`, `problemOf(error)` |
339
+ | `TimeoutError` | `transport:timeout:reply` |
340
+ | `AbortError` (also a deduped read's caller) | `transport:aborted:request` |
341
+ | a network failure (`TypeError`) | `transport:lost:reply`, the `TypeError` as `cause` |
342
+ | a body that is not the JSON it declared (`SyntaxError`) | `remote:unexpected:json` |
343
+ | no CSRF token after the 419 refresh | `transport:missing:csrf` |
344
+ | `error.silent` | `error.context.silent` |
345
+ | `http.safe.*` `error`: the problem object | the `BusError`; `problemOf(error)` reads its problem |
346
+ | a POST with no response re-sent | sent once, unless it carries an `Idempotency-Key` |
347
+ | a non-2xx whose body is not its declared JSON: the client's `SyntaxError` | the status decides on every path: `remote:<condition>:http` |
348
+ | a 2xx that is not the JSON envelope (an HTML login page, an array) on a bridge: a success | `remote:unexpected:json`, the command fails |
349
+ | an empty 2xx body | no data on every path, a success (unchanged) |
350
+
351
+ `tests/http-one-shape.test.ts`; log sections 35.131-35.133.
352
+
353
+ ### Fixed: `history` could not undo a store action; `undo: true` makes it
354
+
355
+ `history` reverses a command only through an inverse registered with it, and
356
+ a store registered its actions with none: `undo()` left the state as it was
357
+ and `redo()` ran the action again. The docs, and the v1.18.0 entry ("`history`
358
+ undoes it"), said otherwise. `defineChamberStore(id, { ..., undo: true })`
359
+ registers each action and `$reset` with an inverse that puts back the state
360
+ the action replaced, run as the `<action>$undo` command like every undo (entry
361
+ below), so persist stores the undone state and a reload keeps it; history does
362
+ not record it. Off by default, and a store without it registers and runs
363
+ exactly as before. `tests/store-undo.test.ts`; log section 35.112.
364
+
365
+ ### Added: `vapor-chamber/store/core`, the store with no Vue
366
+
367
+ The store, for an app on the bus without Vue: the same implementation as
368
+ `vapor-chamber/store` (actions as commands, `$reset`, undo, `$onField`,
369
+ `share`, the coded refusals) over the library's `signal()`, which is a plain
370
+ `{ value }` or, with `configureAlienSignals()`, a reactive signal. No
371
+ component scope, so the caller owns `$dispose()`; `fieldRef` stays in the Vue
372
+ entry. The built entry imports no Vue. `tests/store-core.test.ts`; log section
373
+ 35.130.
374
+
375
+ ### Added: a store shared across tabs, `share`
376
+
377
+ `defineChamberStore(id, { ..., share: lane })`, with the lane bridged by
378
+ `createChannel({ lane, events: ['<id>$state'] })`: each local write sends the
379
+ new state with a version, and other tabs apply a newer one through the
380
+ `<id>$sync` command (origin `'sync'`, which `history` and `useCommandHistory`
381
+ now do not record). Every tab ends on the same state, concurrent writes
382
+ included; a received state is not sent back. It shares state, not commands,
383
+ so a non-deterministic handler cannot make tabs disagree (the reason the old
384
+ sync plugin was removed). A store without it writes exactly as before.
385
+ `tests/store-share.test.ts`; log section 35.129.
386
+
387
+ ### Added: a store's field events, `$onField` and `fieldRef`
388
+
389
+ `store.$onField(key, fn)` calls `fn` with a field's new value each time a write
390
+ changes it; `fieldRef(store, key)` (from `vapor-chamber/store`) is that field as
391
+ a read-only Vue ref, so a component reading one field re-renders only when it
392
+ changes. A store nobody subscribes to writes exactly as before. With several
393
+ readers it is cheaper on every write than a `computed` per field; with one, a
394
+ `computed` is slightly cheaper (docs/store.md). `$onField` is a store member an
395
+ action cannot take. `tests/store-field-events.test.ts`; log section 35.125.
396
+
397
+ ### Removed: `csrf: 'inertia'` on the HTTP bridges
398
+
399
+ It sent no CSRF token at all: the bridges make their own requests, which no
400
+ Axios interceptor of the app touches, and Inertia 3 ships no Axios. On Laravel
401
+ 13 a tokenless POST passes only from the same origin. An Inertia app uses
402
+ `csrf: true`, which reads the `XSRF-TOKEN` cookie Laravel sets when the page
403
+ has no meta tag. `csrf` is now a boolean; passing `'inertia'` is a type error.
404
+ `tests/csrf-option.test-d.ts`; log section 35.124.
405
+
406
+ ### Fixed: `vaporChamberHMR()` no longer trips the shared-bus warning
407
+
408
+ On first load its shim read the shared bus, which created one, so an app
409
+ calling `setCommandBus()` in its setup got the "replaced the bus
410
+ getCommandBus() had created" warning on every dev server start. It now reads
411
+ no bus until a hot update stores it. `tests/hmr-shim-first-load.test.ts`;
412
+ log section 35.123.
413
+
414
+ ### Added: `problemOf(error)`, the backend's problem behind a failure
415
+
416
+ A loader failure is `router:failed:loader` with the http client's error as
417
+ its cause, and an app dug through `cause` for the backend's RFC 9457 problem.
418
+ `problemOf(error)` (root export) walks the cause chain and returns that
419
+ problem: a `remote:` failure, from a bridge or the http client (entry above),
420
+ gives its members. `undefined` when no backend answered. Plan 8d.2;
421
+ `tests/problem-of.test.ts`; log section 35.121.
422
+
423
+ ### Changed: a query change wins over a page navigation still in flight
424
+
425
+ On a page, `push('/list')` held by a slow guard or loader, then
426
+ `setQuery({ q: 'x' })` (or a query-only `push`): the query committed, then
427
+ `/list` committed over it and the change was lost, its history entry left
428
+ behind. The query change now supersedes the pending navigation, as a newer
429
+ navigation does: the query commits on the page on screen and the pending
430
+ `push` answers `router:aborted:navigation` (normal flow, not `onError`). A
431
+ query refetch still never aborts a page navigation.
432
+ `tests/router/query-during-navigation.test.ts`; log section 35.120.
433
+
434
+ ### Changed: `authGuard` refuses with `authGuard:unauthenticated:action`
435
+
436
+ No session is `unauthenticated` (sign in, then the same request), not
437
+ `refused` (a rule said no): the code was `authGuard:refused:action`. A rule
438
+ matching the old code must follow. `tests/plugins.test.ts`; log section 35.119.
439
+
440
+ ### Changed: an app's `$` name is refused; the naming check is coded; `router:failed:history`
441
+
442
+ Names with `$` are the library's (`<id>$reset`, `<action>$undo`), and the
443
+ bridges keep them local, so an app's would have been silently never sent.
444
+ `register()` now refuses one (`core:invalid:name`), whatever the naming rule,
445
+ and so does `createTestBus`; a store refuses a `$` in its id or an action key
446
+ when defined (`store:invalid:name`). The naming check's violation in `throw`
447
+ mode is the same `core:invalid:name` (it was a plain `Error`). A history the
448
+ app supplies that throws writing the URL is `router:failed:history` (it was
449
+ reported as a component failure, which hard-navigated). A store with
450
+ `undo: true` keeps its last 256 steps in a ring instead of weak maps: its
451
+ action cost drops from about +800 ns to about +60 ns. `createTestBus` registers
452
+ `<action>$undo` like the real bus. `tests/library-names.test.ts`; log sections
453
+ 35.117, 35.118.
454
+
455
+ ### Changed: reads answer plain JSON; `unwrapRoutesPayload` is `readRoutesPayload`
456
+
457
+ A read (a loader, the routes table) answers the body itself, and a failure
458
+ is a non-2xx `application/problem+json`; the command envelope (`{ state }`, a
459
+ `{ problem }` on a 2xx) is not a read's shape. The loader preset already read
460
+ plain JSON, but the routes reader also accepted `{ state }` and a 2xx
461
+ `{ problem }`, guessing between two shapes. It now reads the bare
462
+ `{ routes, base? }` only: an envelope is `router:unexpected:routes`, a failed
463
+ fetch `router:failed:routes` with the client's error as its cause. The
464
+ export is renamed to say what it does. A backend that wrapped its routes table
465
+ in `{ state }` returns the table itself. `tests/router/routes-plain-json.test.ts`;
466
+ log section 35.115.
467
+
468
+ ### Changed: every undo is a command, `<action>$undo`
469
+
470
+ `register(action, handler, { undo })` also registers `<action>$undo`, and every
471
+ undo runs through it: `history` / `useCommandHistory`, `optimisticUndo`'s
472
+ rollback and a transactional batch's rollback. Before, each called the inverse
473
+ directly, so no listener, devtools timeline or persist heard an undo. The
474
+ command's target is the command it reverses (the inverse still receives that
475
+ command), its origin `'undo'`, its `causationId` that command's id. A plugin
476
+ can refuse it like any command: history then keeps the step, and a refused
477
+ rollback is reported (`rollbacks`, `onRollbackError`). `$undo` names show in
478
+ `registeredActions()` and `inspectBus().actions`. On the async bus an undo is
479
+ now async like every dispatch, so a `redo()` in the same tick as an `undo()`
480
+ does nothing (one undo or redo in flight at a time, as before). The library's
481
+ `$` commands (`$undo`, `$reset`) are local: no bridge sends them and the outbox
482
+ never queues them, whatever their `actions` patterns. Log section 35.114.
483
+
484
+ ### Added: `canUndo` beside `undo` in `register()`
485
+
486
+ `register(action, handler, { undo, canUndo })`: `canUndo(cmd)` says whether
487
+ the inverse can still run for that recorded command. `history().getState()`
488
+ and `useCommandHistory()` report `canUndo` from it for the command on top, and
489
+ `undo()` does nothing while it is false. Without it, `canUndo` is "the history
490
+ is not empty", as before. A store defined with `undo: true` uses it: an undo
491
+ waits while a change the history did not record has moved the state.
492
+ `tests/undo-check.test.ts`, `tests/store-undo.test.ts`; log section 35.113.
493
+
494
+ ### Changed: the router follows the core's failure model
495
+
496
+ The entries above gave every failure the library raises one shape: a
497
+ `BusError` coded `owner:condition:subject`, listed in `ERROR_CODE_REGISTRY`,
498
+ read by `conditionOf` / `failureCondition`, a stack only for `failed`, an RFC
499
+ 9457 problem at a boundary. The router was the one part left outside it: its
500
+ failure was a plain `Error` named `RouterError` with a snake_case code that
501
+ no registry listed and no core reader understood (`failureCondition` read
502
+ every one as `failed`). It now uses the same model, owner `router`: nothing
503
+ here is a router feature, each change below is what the core's rules give
504
+ it. **Every code changes**, and a handler that matches them must follow:
505
+
506
+ | before | now |
507
+ |---|---|
508
+ | `unmatched` | `router:missing:route` |
509
+ | `aborted` (a guard returned false) | `router:refused:guard` |
510
+ | `cancelled` (superseded, destroy) | `router:aborted:navigation` |
511
+ | `not_ready` | `router:missing:routes` |
512
+ | `unknown_route_name` | `router:missing:record` |
513
+ | `redirect_loop` | `router:exceeded:redirects` |
514
+ | `duplicate_route` | `router:already:route` |
515
+ | `unknown_parent` | `router:missing:parent` |
516
+ | `cyclic_parent` | `router:invalid:parent` |
517
+ | `missing_param` | `router:missing:param` |
518
+ | `bad_menu_row` | `router:invalid:menu` |
519
+ | `invalid_path` | `router:invalid:path` |
520
+ | `invalid_routes_payload` | `router:unexpected:routes` |
521
+ | `routes_load_failed` | `router:failed:routes`; `reload()` without a `{ url }` source: `router:missing:url` |
522
+ | `inline_routes_missing` | `router:missing:inline` |
523
+ | `component_missing` | `router:missing:component` |
524
+ | `component_load_failed` | `router:failed:component`; a guard that throws: `router:failed:guard` |
525
+ | `load_failed` | `router:failed:loader`; a `load` no handler serves: `router:missing:loader` |
526
+ | `blade_unconfigured` | `router:missing:fetchBlade` |
527
+ | `http_unconfigured` | `router:missing:http` |
528
+ | `blade_fetch_failed` | `router:failed:blade` |
529
+ | `mode_mismatch` | `router:invalid:component` |
530
+ | `no_router` | `router:missing:router` |
531
+
532
+ Also changed: `isRouterError(error, code)` takes the full code; `error.name`
533
+ is `BusError`; the navigation target is `error.context.to` (was `error.to`);
534
+ the message has no `[vapor-chamber-router]` prefix (the code names the
535
+ source); only a `failed` code captures a stack, so a superseded navigation or
536
+ a guard's refusal no longer pays for one; `toJSON()` gives the RFC 9457
537
+ problem every failure gives. Read as conditions, three router codes were two
538
+ failures each, and are split: `router:missing:url`, `router:missing:loader`
539
+ (nothing was tried, so not `failed`) and `router:failed:guard`.
540
+ `HARD_NAV_CODES` follows the plan's rule, a route, component or server HTML
541
+ that is missing or failed, and so gains `router:missing:component`: an
542
+ unregistered component key now hands the URL to the server, as an unmatched
543
+ one does. A guard that throws no longer hard-navigates: it was reported as a
544
+ component load failure, and a reload cannot fix a guard bug. `router` +0.3 KB
545
+ brotli, `router/vdom` and `router/vapor` +0.3 KB each (they now carry
546
+ `BusError`), `router-fetch` +0.3 KB, the root entry +1.4 KB brotli for the 26
547
+ registry rows (tree-shaken unless you read the registry).
548
+ `tests/router/router-codes.test.ts`; log section 35.108.
549
+
550
+ ### Changed: Vue's async Vapor code only in apps that use it (`vaporChamberWire({ entry: 'vapor' })`)
551
+
552
+ `vapor-chamber/vapor` handed Vue's `defineVaporAsyncComponent` to the
553
+ registry so the root's wrapper could reach it, which kept Vue's async
554
+ component code in every `/vapor` app, whether it defined an async component
555
+ or not. In a Vite build with `vaporChamberWire({ entry: 'vapor' })` the root's
556
+ `defineVaporAsyncComponent` is now `/vapor`'s, which calls Vue's directly, and
557
+ the registry is not seeded: an app that never defines an async component
558
+ ships none of Vue's async code, and one that does gets a component in
559
+ production as before. Every other build (esbuild, webpack, Vite without the
560
+ plugin) keeps the seed and today's behaviour. `vapor-chamber/vapor`'s
561
+ `defineVaporAsyncComponent` is its own function now (it never returns null),
562
+ no longer the root's. `./vite` +0.1 KB brotli, `./vapor` unchanged in brotli.
563
+ `tests/vapor-async-wire.test.ts`, `tests/vapor-async-seed.test.ts`; log
564
+ section 35.109.
565
+
566
+ ### Faster for keyed commands: `meta.idempotencyKey` is always there
567
+
568
+ A command's `meta` gained `idempotencyKey` only when `idempotent()`, the outbox
569
+ or the async retry stamped one, which moved every keyed command onto a second
570
+ hidden class. It is now a slot of every `meta`, `undefined` until stamped, so
571
+ keyed and plain commands share one shape (`'idempotencyKey' in cmd.meta` is
572
+ now always true; JSON still drops it while undefined). A plain dispatch
573
+ measured unchanged (B/A 1.000-1.008, ~22 ns). IIFEs +2 to +9 B brotli.
574
+ `tests/v8-shapes.test.ts`; log section 35.81.
575
+
576
+ ### Added: `HttpResponse.url` and `redirected`
577
+
578
+ The HTTP client dropped two members Fetch gives every response, so an app
579
+ its server redirected could not learn where it ended up. `createHttpClient()`
580
+ and `postCommand()` now set `url` (the final URL, after redirects) and
581
+ `redirected`. Both are optional in the type, for an app that passes its own
582
+ `httpClient` to a bridge. IIFEs +8 to +11 B brotli.
583
+ `tests/http-response-url.test.ts` (a real local server: a stub has no url);
584
+ log section 35.82.
585
+
586
+ ### Added: `resolveNotModified`, a 304 that resolves; fixed: a GET joined to another with different headers
587
+
588
+ A 304 answers a conditional request the app sent itself (`If-None-Match`,
589
+ `If-Modified-Since`), and the client threw it like any non-2xx; no plugin or
590
+ interceptor could change that. With `resolveNotModified: true`, per request
591
+ or per client (and on `postCommand`), it resolves as it is: `status` 304,
592
+ `ok` false, the empty body. Off by default, so code catching it is unchanged.
593
+
594
+ Fixed with it: two GETs of one URL shared one request whatever their
595
+ headers, so a plain read that started while a conditional one was in flight
596
+ received that read's 304 (and a read with another `Authorization` or
597
+ `Accept-Language` got the first one's answer). A GET now joins one in flight
598
+ only when its headers match, and an opted-in read never joins one that is
599
+ not. IIFEs +32 to +38 B brotli. `tests/http-not-modified.test.ts` (a real
600
+ local server); log section 35.83.
601
+
602
+ ### Fixed: the router re-review
603
+
604
+ Each fix has a test that failed before it; the record is
605
+ `docs/rc-alignment-log.md`, sections 35.90 onward.
606
+
607
+ - **Two quick Backs during a slow page ended a page off.** A navigation the
608
+ user superseded walked the history back by its own step, which the router
609
+ then read as a third navigation: the page settled on the first Back's page
610
+ with the address bar elsewhere. A cancelled navigation now walks nothing
611
+ (vue-router does the same); a refused or failed one still walks back.
612
+ `tests/router/superseded-navigation-history.test.ts`; log section 35.90.
613
+ - **A navigation to the page on screen let a pending one commit after it.**
614
+ On `/`, a click to a slow `/list` and then a click back to `/` ended on
615
+ `/list`, with `push('/')` having answered null; Forward back to the page
616
+ during a held Back committed the Back's page under the Forward's address.
617
+ The page on screen, asked for again, now supersedes the navigation in
618
+ flight like any newer one (it answers `cancelled`, its loaders abort);
619
+ with nothing in flight it still changes nothing.
620
+ `tests/router/navigation-to-current-page.test.ts`, and both cases in real
621
+ Chromium, `tests/browser/router-history.browser.test.ts`; log section 35.91.
622
+ - **`router.destroy()` during `start()` left a live router behind.** With a
623
+ `{ url }` table, a destroy while the table loaded let start() go on to
624
+ register its history, link and announcer listeners and commit the first
625
+ navigation after the teardowns had run, so a link click still navigated
626
+ it; a destroy during the first navigation left the idle preheat's window
627
+ listeners in place. start() now stops at both points.
628
+ `tests/router/destroy-during-start.test.ts`; log section 35.92.
629
+ - **Inline routes that were not JSON failed with a plain `SyntaxError`.** The
630
+ one inline failure with no code; it is now `invalid_routes_payload`, the
631
+ parse error as its `cause`. `tests/router/inline-routes-json.test.ts`; log
632
+ section 35.93.
633
+ - **One hidden class for every `RouterError`.** `to` and `cause` were added
634
+ only when given, so the errors the navigation path builds came in up to
635
+ four shapes; both are now always own properties, `undefined` when not
636
+ given (`'to' in error` is now always true; reading either is unchanged).
637
+ `tests/router/router-error-shape.test.ts`; log section 35.94.
638
+ - **Docs corrected to the code.** The `vapor-chamber/router/vapor` row of the
639
+ retained-bindings tables (README, docs/router.md) now lists `createIf`;
640
+ `RouteLocation.path` is documented as the path as in the URL
641
+ (percent-encoded, params decoded), not "decoded"; an untested "~20us per
642
+ navigation" figure is gone from docs/router.md.
643
+ `tests/router/location-path-encoding.test.ts`,
644
+ `tests/router/vapor-boundary.test.ts`; log section 35.95.
645
+ ### Changed: `OutboxRecord.queuedAt` is an RFC 3339 string
646
+
647
+ The outbox stamped each queued record with `Date.now()`, an epoch number
648
+ that says neither its unit nor its zone, on a record that leaves the
649
+ process (storage, or a backend a custom `OutboxStorage` syncs to). It is now
650
+ `new Date().toISOString()`, RFC 3339 in UTC with milliseconds
651
+ (`2026-10-02T17:41:19.250Z`), and the type is `string`.
652
+ `Date.parse(record.queuedAt)` gives the milliseconds back. The outbox never
653
+ reads the field, so replay is unchanged; a record persisted with a number
654
+ still hydrates and replays. `dist/outbox.js` +17 B brotli, no IIFE change.
655
+ `tests/outbox.test.ts`; log section 35.84.
656
+
657
+ ### Changed: the outbox drops a record a validator rejects at replay
658
+
659
+ The outbox's default `isRetryable` kept every failure the library or a
660
+ plugin raised, so a queued command that `validator()` or `schemaValidator()`
661
+ rejected stayed at the head of the queue for good: the outbox captures before
662
+ any validator runs, the same record gets the same `invalid` answer on every
663
+ flush, and every record behind it waited for ever. Such a failure (condition
664
+ `invalid`) is now final: the record is dropped, `'outboxRejected'` reports it
665
+ with its error, and the flush moves on, as a live dispatch would have been
666
+ refused. Every other failure the library raised is still kept: a handler not
667
+ registered yet, a plugin that threw, an `authGuard` refusal (replayed after
668
+ sign-in). A rule of your own (`isRetryable`) is unchanged. `dist/outbox.js`
669
+ +13 B brotli, no IIFE change. `tests/outbox.test.ts`; log section 35.85.
670
+
671
+ ### Fixed: two outbox defects around `Retry-After`
672
+
673
+ Both have a test in `tests/wire-contract.test.ts` that failed before.
674
+
675
+ - **`Retry-After: 0` looped.** A kept record is flushed again after the wait
676
+ its answer declared. With a wait of zero the outbox re-flushed at once, got
677
+ the same answer, and re-flushed again: 500 requests with no time passing
678
+ between them, in the test. A scheduled re-flush now waits at least one
679
+ second. The record still drains by itself when the backend recovers.
680
+ - **A `409` that declared a `Retry-After` was dropped as a verdict.** A
681
+ `409` for a request still in progress, sent with `Retry-After`, says when
682
+ to come back. Any answer that declares a `Retry-After` now keeps the
683
+ record, and the outbox flushes again then. A `409` with no `Retry-After` is
684
+ rejected as before (`remote:conflict:...`).
685
+
686
+ `autoFlush`'s documentation said "No timers"; the `Retry-After` wait is one,
687
+ and the text says so. `dist/outbox.js` +22 B brotli, no IIFE change. Log
688
+ section 35.86.
689
+
690
+ ### Changed: the outbox backs off while a backend keeps answering `Retry-After`
691
+
692
+ A backend that answered every replay with `Retry-After: 1` was asked once a
693
+ second for as long as it stayed busy: 601 requests over a 10-minute outage.
694
+ The outbox's scheduled re-flush now doubles its wait while the answer
695
+ repeats (1 s, 2 s, 4 s, ...) and stops growing at 30 s: 24 requests over the
696
+ same outage, and the record drains a second after the backend recovers in
697
+ that run, always within 30 s. A declared wait longer than the schedule wins
698
+ (`Retry-After` is a minimum). A successful replay starts it over at 1 s. A
699
+ `flush()` or an `'online'` event during a wait replaces that wait instead of
700
+ starting a second timer. `dist/outbox.js` +36 B brotli, no IIFE change.
701
+ `tests/wire-contract.test.ts`; log section 35.87.
702
+
703
+ ### Fixed: three ways a store broke without a word
704
+
705
+ From a re-review of `vapor-chamber/store`, each with a test that failed before
706
+ (`tests/store-review.test.ts`):
707
+
708
+ - **An action named like a store member replaced it.** `actions: { state }`
709
+ left `store.state` a function, so `store.state.value` read `undefined`;
710
+ `actions: { $reset }` was overwritten by the library's own reset.
711
+ `defineChamberStore` now throws for an action key `$id`, `state`, `url`,
712
+ `$reset` or `$dispose`, naming it.
713
+ - **A store with `url` fields called without a router left its handlers on
714
+ the bus.** The error came after they were registered, so nothing could
715
+ unregister them, and the next call (with the router) registered over them
716
+ with a warning per action. The check now comes first.
717
+ - **A stale `$dispose()` removed the newer store.** After a store was
718
+ disposed and its id taken again, a second `$dispose()` of the old one, or a
719
+ scope that held the old one ending late, deleted the new store from the
720
+ registry: the next `useStore` built a third store over its handlers, and
721
+ the second one's state stopped changing. `$dispose()` now removes only its
722
+ own entry.
723
+
724
+ `dist/store.js` +90 B brotli, no IIFE change. Log section 35.88.
725
+
726
+ ### Changed: the store's refusals carry codes
727
+
728
+ Every failure the library raises is `owner:condition:subject`; the store's
729
+ three setup refusals were plain `Error`s. They are now `store:missing:bus`
730
+ (`useStore()` with no bus), `store:missing:router` (url fields, no router) and
731
+ `store:already:member` (an action named like a store member), each in
732
+ `ERROR_CODE_REGISTRY` and `docs/errors.md`; the messages are unchanged.
733
+ `dist/store.js` now imports `./command-bus.js` beside `vue` (no probe, and
734
+ every store user loads it already for the bus). `dist/store.js` +51 B
735
+ brotli, `dist/index.js` +146 (the registry entries); no IIFE change.
736
+ `tests/store-review.test.ts`; log section 35.88b.
737
+
738
+ ### Fixed: a `Retry-After` over 30 seconds was ignored, and a long one was cut short
739
+
740
+ `Retry-After` is the minimum time to wait (RFC 9110). The parser dropped any
741
+ value over 30 s, a policy of the HTTP client living where every caller reads,
742
+ so: the HTTP client retried a `Retry-After: 120` at once on its own backoff;
743
+ the outbox saw no wait, and a `409` sent with `Retry-After: 120` was rejected
744
+ as a verdict; and the async bus re-sent a declared wait longer than its
745
+ `maxDelay` (20 s) at `maxDelay`, sooner than asked. The parser now reads what
746
+ was declared (ignoring only a wait no timer can hold, over about 24.8 days),
747
+ and each caller keeps its own policy: the HTTP client does not sleep a
748
+ declared wait over 30 s inside one request (the request fails, the header on
749
+ the failure), the async bus returns a declared wait longer than `maxDelay`
750
+ instead of re-sending early, and the outbox waits it out. IIFEs brotli -6
751
+ full, +12 core, +23 elements. `tests/retry-after-long.test.ts`; log section
752
+ 35.89.
753
+
754
+ ### Fixed: a `Retry-After` date is read as GMT, and only an HTTP-date is read
755
+
756
+ `Retry-After` may carry a date instead of seconds, and RFC 9110 names three
757
+ forms for it, all in GMT. The client read the date with `Date.parse`, which
758
+ takes the oldest of them (`Sun Nov 6 08:49:37 1994`, no zone written) as
759
+ LOCAL time - three hours late on a machine at UTC-3 - and takes any other
760
+ date it understands too (`2026-10-01T12:00:10Z`, `10/1/2026`, a numeric
761
+ offset, lowercase or made-up day names). It now reads the three HTTP-date
762
+ forms only, each as GMT, and ignores anything else, as it already ignored a
763
+ malformed number of seconds. A two-digit year (the obsolete second form)
764
+ reads as its next occurrence. IIFEs +250 to +286 B brotli, the Blade bundle
765
+ +272. `tests/retry-after-grammar.test.ts`; log section 35.100.
766
+
767
+ ### Fixed: a store defined again takes over, as a handler registered again does
768
+
769
+ A second `defineChamberStore` with an id a store still held got the first
770
+ store back: without its own actions, running the old reducers, with no
771
+ word. A store created when its module loads kept its first definition
772
+ through every hot reload, so edited reducers never applied. Now a different
773
+ definition takes over, the rule `register()` follows for a handler: it builds
774
+ its own store and registers its actions over the old ones (in development,
775
+ `register()` warns for each one, which is how two modules claiming one id
776
+ show), and the old store's `$dispose()` removes only what it still owns. The
777
+ same definition called again still returns the one store. A component that
778
+ holds a store was not affected: Vue disposes it before a reload. `dist/store.js`
779
+ +16 B brotli, no IIFE change. `tests/store-redefine.test.ts`,
780
+ `tests/vapor/store-hmr.test.ts`; log section 35.101.
781
+
782
+ ### Changed: the store's refusals read like the core's
783
+
784
+ A store's three setup refusals (`store:already:member`, `store:missing:bus`,
785
+ `store:missing:router`) now have the shape every core failure has: the
786
+ message states the fact, every value in it is also in `error.context`
787
+ (`id`, the `key`, the url `fields`), and the advice on what to do next is
788
+ printed in development only - production carries the fact, and the fix lives
789
+ in the catalogue (`getErrorEntry(code).fix`). The `[vapor-chamber]` prefix
790
+ went with it; no core failure carries one. Codes unchanged. A consumer's
791
+ production build of `vapor-chamber/store` is 108 B brotli smaller.
792
+ `tests/store-review.test.ts`; log section 35.102.
793
+
794
+ ### Docs: loading saved state into a store, from storage or from the server
795
+
796
+ `docs/store.md` has one pattern for it: a store has no setter, so saved state
797
+ enters through an action the store declares (`load: (s, saved) => saved ?? s`),
798
+ called with `persist().load()` or with the state the server put in the page.
799
+ From the server, send the state rather than the commands that built it:
800
+ replaying commands re-runs every reducer on the client, and one that is not
801
+ deterministic ends somewhere else. `__origin: 'replay'` in the payload marks
802
+ the load as restored state for listeners. No code change.
803
+ `tests/store-load-saved.test.ts`; log section 35.103.
804
+
805
+ ### Changed: development dependencies updated within their ranges
806
+
807
+ No major version: vite 8.3.2, vitest 5.0.3 (with its coverage and browser
808
+ packages), @vitejs/plugin-vue 6.0.9, vue-tsc 3.3.12, astro 7.3.5 (the
809
+ exo-astro example), biome 2.5.15, happy-dom 20.14.5, @types/node 26.6.4.
810
+ `npm audit fix` cleared `devalue`; `http-cache-semantics`, reached only
811
+ through the exo-astro example's astro, has no fixed version. The library's
812
+ own dependencies are unchanged (it has none at run time). The full IIFE is 3 B
813
+ brotli larger from the minifier's output; no library code changed.
814
+
815
+ ### Docs: the whitepaper rewritten
816
+
817
+ `docs/whitepaper.md` now describes the library as it is: 17 sections and a new
818
+ one, Performance and V8 (how a speed claim is measured here, and the findings
819
+ in ns and ratios). The origins, the Vue 3.6 alignment log (beta.8 to rc.10)
820
+ and the passages in which the document corrected itself moved verbatim to an
821
+ appendix, "Notes and history". Six wrong statements were corrected (among
822
+ them: `retry` listed as a plugin, naming called "enforced", an example passing
823
+ a `plugins` option `createCommandBus` does not have) and seven unbacked
824
+ figures dropped or replaced by stamped ones; the record is
825
+ `docs/rc-alignment-log.md`, section 35.68.
826
+
827
+ ### Docs: the peer bench ratios restamped
828
+
829
+ The 16 ratios against mitt and nanoevents in `docs/performance.md`, the
830
+ migration guides and the ROADMAP had drifted with Node and the peers'
831
+ versions, not with this library's code. Each is now the median of 5
832
+ `npm run bench` runs from a clean tree (Node 24.21, mitt 3.0.1, nanoevents
833
+ 9.1.0). The default fast lane's fan-out no longer reads ahead of nanoevents:
834
+ it is about level (0.91-1.03 across runs), and the guide says so. Log section
835
+ 35.56.
836
+
837
+ Then nanoevents left the docs, the bench and the dev dependencies (owner). Its
838
+ lead came from a micro-loop emitting one constant name, which V8 specialises:
839
+ with four varying names it emits at about half this library's speed with no
840
+ listeners, and its plain-object table throws on `emit('constructor')`. The docs
841
+ compare this library with itself and with mitt, which has a migration guide.
842
+ Log sections 35.57 and 35.58.
843
+
844
+ Then a ratio against another library became a range, not one value: `npm run
845
+ bench:bands` runs the bench N times and stamps each peer ratio as its min-max,
846
+ with the Node and peer versions, and each of this library's own ratios as the
847
+ median; a single `npm run bench` no longer moves a doc. eventemitter3, the
848
+ EventEmitter guide's peer, is now compared too, and the docs say what the
849
+ bench says: the general bus's `emit` is behind it on fan-out (0.78-0.82x),
850
+ the fast lane level with it (0.94-1.04x). Two hand-typed figures are now
851
+ stamped: `bus.emit` on fan-out, 1.44-1.54x mitt ("~1.5x" held), and mitt's
852
+ emit against `bus.dispatch`, 1.64-2.04x (it said "~3x"). Log section 35.69.
853
+
854
+ ### CI: a speed check that checks
855
+
856
+ The job named "bench smoke (no regression vs baseline)" compared against
857
+ nothing; it is now "bench (report only)". A new job, `perf-ab`, builds the
858
+ pushed commit and its base and compares them with the repository's A/B tool
859
+ (`npm run ab:ci`) over five workloads: it fails on a measured regression, and
860
+ on a run too noisy to have measured anything. Verified locally: identical
861
+ builds pass, and a seeded +37-41 ns per dispatch fails on every row of the
862
+ dispatch workload. Log section 35.59.
863
+
864
+ With no base to build, `perf-ab` now skips and says why, where it failed with
865
+ no message: an all-zero base (the push that creates the branch) or one a
866
+ force-push dropped from the checkout. `scripts/ab/build-dists.sh` also prints
867
+ git's error when it cannot check a commit out. Log section 35.71.
868
+
869
+ ### `VcTransition`: a transition for Vapor, driven by the bus (new subpath, experimental)
870
+
871
+ `vapor-chamber/transitions/vapor` exports `VcTransition`. It imports nothing of
872
+ Vue's transition. The component owns the condition: the content is rendered
873
+ while `show` is true, and when `show` turns false it stays in the DOM until the
874
+ `<namespace>Leave` command has answered. The look comes from command handlers;
875
+ there are no CSS classes.
876
+
877
+ ```vue
878
+ <VcTransition :show="open" namespace="modal" :bus="bus">
879
+ <Panel />
880
+ </VcTransition>
881
+ ```
882
+
883
+ - **The same six actions as `createTransitionBridge`**, under the same names:
884
+ `modalBeforeEnter`, `modalEnter`, `modalAfterEnter`, `modalBeforeLeave`,
885
+ `modalLeave`, `modalAfterLeave` (`beforeEnter` and so on with no namespace).
886
+ The target is the first element of the content. `AfterLeave` is dispatched
887
+ once the element is out of the document.
888
+ - **What is awaited is the dispatch**, as in the bridge. An async bus returns
889
+ a promise and the transition waits for it, for at most `timeout`
890
+ milliseconds (default 30,000): a handler that never settles cannot strand
891
+ the element, and one that rejects, or a missing one, does not stop it either.
892
+ A sync bus answers at once, whatever its handler returned, so there the
893
+ element leaves at once.
894
+ - **`:bus` is optional**; without it the shared bus is used. The shared bus is
895
+ a sync bus: for a leave that must be waited for, pass an async bus.
896
+ - **Reopening during a leave cancels the leave.** `<namespace>LeaveCancelled`
897
+ is dispatched, the same element enters again, and the older answer is
898
+ ignored. Closing during an enter dispatches `<namespace>EnterCancelled` and
899
+ then the leave commands. Those are the bridge's two cancelled actions, in the
900
+ order Vue calls them for a `v-show`; a sync bus never produces one, since
901
+ nothing is ever waiting on it.
902
+ - **Mounting dispatches nothing**, unless `appear` is set: then content that
903
+ is there at mount gets the three enter commands.
904
+ - Vapor only, Vue >= 3.6, like `vapor-chamber/router/vapor`. It is named by the
905
+ rule now recorded in `docs/decisions.md`: `Vc` + the name of the Vue piece it
906
+ stands in for, opt-in, never a swap of Vue's own name.
907
+
908
+ Why it exists, measured on Vue 3.6.0-rc.10 in a Vite production bundle: Vue's
909
+ `VaporTransition` adds 4,400 B brotli to a Vapor app, 1,127 B of it CSS class
910
+ machinery that a command-driven transition never uses and no exported piece
911
+ lets a bundle leave out; with the bridge it is 4,737 B. `VcTransition` adds
912
+ 428 B brotli (1,106 B minified) to an app that already uses the shared bus and
913
+ the five Vue helpers it is built from (`createIf`, `shallowRef`, `watch`,
914
+ `nextTick`, `onScopeDispose`), and 2,534 B to one that uses none of the five.
915
+ A show and hide cycle on a sync bus takes 0.39 of the time
916
+ `<Transition :css="false">` with the bridge takes (7.4 against 19.1
917
+ microseconds, production build, 15 interleaved rounds).
918
+
919
+ `createTransitionBridge` and `useTransitionCommand` are unchanged: they remain
920
+ the way to drive Vue's own `<Transition>` from the bus, on Vapor and on vDOM.
921
+
922
+ `tests/vapor/vc-transition.test.ts` runs every behaviour above twice, on the
923
+ dev build and against an executed production bundle, from templates compiled on
924
+ the installed Vue. `tests/vapor/vc-transition-helpers.test.ts` pins what the
925
+ module takes from Vue.
926
+
927
+ ### Vue 3.6.0-rc.10 alignment
928
+
929
+ 85 commits in `v3.6.0-rc.9..v3.6.0-rc.10`, the full log. Eighty-three were read
930
+ in full at source; the other two are the Vitest 5 chore and the release commit,
931
+ closed on their touched files. The peer range narrows to
932
+ `>=3.5.0 || >=3.6.0-rc.10` for `vue` and `@vue/reactivity`; both Vapor examples
933
+ are repinned and rebuilt. The alignment itself changed nothing in `src` except
934
+ two comments; the outlet fix below is separate. The cycle record is
935
+ `docs/rc-alignment-log.md`, section 35.
936
+
937
+ **What the bump gives you, each pinned by a test that fails on rc.9.**
938
+
939
+ - **`createDirectivePlugin()` on a Vapor child of a vDOM parent.** With
940
+ `vaporInteropPlugin`, `<VaporChild v-vc-command="act" />` kept its listener on
941
+ the child's first root. When a root `v-if` / `v-else` replaced that element,
942
+ the new one dispatched nothing, the detached one still did, and a `.delegate`
943
+ registration never released its document listener. On rc.10 the directive
944
+ follows the root. `tests/interop-vdom-directive-fixture.test.ts`.
945
+ - **A function set on a widget property arrives as the function.**
946
+ `el.onSave = fn` on a `defineWidget` element was called as a prop getter on
947
+ rc.9, and `setup(props)` received what it returned.
948
+ `tests/vapor/widget-shape.test.ts`.
949
+ - **`:onClick="fn"` beside `v-vc-command` in a Vapor template is a listener.**
950
+ On rc.9 it compiled to a property write and never ran. It now runs before the
951
+ directive and can veto the dispatch the way `@click` does.
952
+ `tests/directives-vapor-fixture.test.ts`.
953
+
954
+ **Upgrade notes.**
955
+
956
+ - **Server and client on the same RC.** rc.10 changes what the server renders:
957
+ a slot outlet showing its fallback is wrapped in `<!--(-->` ... `<!--)-->`,
958
+ and a Vapor `<TransitionGroup>` keeps its children's fragment markers. An
959
+ rc.9 client does not know the new marker. This is Vue's format, not this
960
+ library's: `vapor-chamber/ssr` holds no DOM and is unchanged.
961
+ - **Rows that register one action.** When rows join a keyed list that already
962
+ has rows, rc.10 creates them in source order; rc.9 created them last to
963
+ first. `register()` is last-wins, so if several rows register the same action
964
+ the owner is now the LAST inserted row, and `on()` listeners run in source
965
+ order. One row per action is unaffected.
966
+ - **A function as a widget property** is no longer called for you. If you
967
+ relied on `el.value = () => compute()` being evaluated, pass the value.
968
+ - **A function as a ROOT prop is still a getter**, on both RCs:
969
+ `createVaporChamberApp(App, { onSave: fn })` calls `fn`. Pass
970
+ `{ onSave: () => fn }`.
971
+
972
+ **Measured.** `npm run ab:vue -- 3.6.0-rc.9`, five runs: scope create and
973
+ dispose 1.027x under a 4.0% floor, ref write 0.997x under a 1.5% floor. Both
974
+ read SAME. Vue's Vapor runtime grew: the hand-wired `createVaporApp` minimum
975
+ went from 15.1 to 16.0 KB brotli, `+ vaporInteropPlugin` from 26.4 to 28.0, and
976
+ the Vapor outlet's own machinery from 4.13 to 4.58 against a ceiling of 5.0.
977
+ Every vapor-chamber export row is unchanged.
978
+
979
+ **Found and not fixed**, recorded with a reproduction in log section 35.10: vDOM
980
+ `<Transition mode="out-in">` throws when `done()` is called synchronously. A
981
+ plain `onLeave(el, done) { done() }` does it with no bridge involved; the
982
+ transition bridge on a sync bus is one way to reach it. It is on rc.9 as well,
983
+ and on Vue 3.5.43, in real Chromium as under happy-dom (log section 35.16).
984
+ Reported upstream as vuejs/core#15727; a fix is proposed in vuejs/core#11824,
985
+ and on that pull request's own build every arm of the reproduction renders,
986
+ while the default mode and `in-out` are unchanged (log section 35.39). The
987
+ limit is now documented on `createTransitionBridge`: with the bridge, vDOM
988
+ `out-in` needs an async bus until a Vue release carries the fix.
989
+
990
+ ### Fixed: the Vapor `RouterOutlet`'s default slot went dead after a child route
991
+
992
+ `vapor-chamber/router/vapor`. A default slot is what the outlet shows when no
993
+ route matches at its depth. If that slot held anything reactive (a `{{ }}`, a
994
+ `v-if`), it stopped updating once a child route had been shown and the outlet
995
+ returned to the slot: it came back with the DOM it had when it left, and no
996
+ later change reached it. A static slot was unaffected, which is why the fixture
997
+ never saw it.
998
+
999
+ The outlet built the slot once in `setup` and handed the same block back. Vue
1000
+ stops a fragment's scope when it removes it, so the second time there was no
1001
+ effect behind the nodes. The slot is now created each time the no-match branch
1002
+ is entered, through `createIf`, which is the call compiler-vapor emits for
1003
+ `<component :is="c" v-if="c" /><slot v-else />`. An outlet with no default
1004
+ slot is unchanged and still leaves one anchor node.
1005
+
1006
+ Cost, measured: the module alone grows 49 bytes minified, 30 brotli, and its
1007
+ `BUNDLE-SIZES.md` row does not move at the table's resolution (0.4 KB brotli).
1008
+ In an app that has no `v-if` of its own, `createIf` joins the bundle and the
1009
+ outlet's own machinery goes from 4.58 to 4.74 KB brotli against the 5.0
1010
+ ceiling; an app that already uses `v-if` pays only the module's 30 bytes.
1011
+ `tests/vapor/vapor-outlet.test.ts` has the reactive-slot arm, on the dev build
1012
+ and on an executed production build; it failed on both before the fix.
1013
+
1014
+ ### Fixed: a conditional default slot on the Vapor `RouterOutlet` never appeared if it started off
1015
+
1016
+ `vapor-chamber/router/vapor`. With
1017
+
1018
+ ```vue
1019
+ <RouterOutlet>
1020
+ <template v-if="ready" #default>No page here</template>
1021
+ </RouterOutlet>
1022
+ ```
1023
+
1024
+ and `ready` false when the outlet was created, the fallback never showed: not
1025
+ when `ready` turned true, and not after a child route had come and gone. The
1026
+ same template with `ready` true at the start worked, and so did an
1027
+ unconditional slot. It is on v1.25.0 and Vue 3.6.0-rc.9 as well.
1028
+
1029
+ The outlet read `slots.default` once, in `setup`, to decide whether it had a
1030
+ fallback. A conditional slot is a dynamic slot source, and while its condition
1031
+ is off there is no `slots.default`, so the outlet took the no-slot path for its
1032
+ whole life. It now takes the slot path whenever the slots it was given carry a
1033
+ dynamic source, and `createSlot` follows the source from there. An outlet with
1034
+ no slot at all is unchanged.
1035
+
1036
+ Cost, measured: 18 bytes brotli in the module (471 to 489); its
1037
+ `BUNDLE-SIZES.md` row and the outlet guard's own-arm number do not move (0.4 and
1038
+ 4.74 KB). `tests/vapor/vapor-outlet.test.ts` has the conditional-slot arm, on
1039
+ the dev build and on an executed production build; it failed on both before the
1040
+ fix. The outlet now reads one more property off what Vapor hands `setup`
1041
+ (`rawSlots`, beside `slots`); `tests/vapor/vapor-outlet-helpers.test.ts` pins
1042
+ it on the installed Vue, and if it is ever absent the outlet falls back to the
1043
+ old behaviour and does not throw.
1044
+
1045
+ ### Documented: a function as a root prop is a getter
1046
+
1047
+ `createVaporChamberApp(App, rootProps)` passes `rootProps` to Vue as raw props,
1048
+ where a function is a getter. `{ onSave: fn }` calls `fn` and hands the
1049
+ component its return value; write `{ onSave: () => fn }`. The docblock now says
1050
+ so, with a test (`tests/vapor/vapor-subpath-wiring.test.ts`). Nothing changed
1051
+ in behaviour.
1052
+
1053
+ ### Fixed: a throwing sync subscriber left `useCommand`'s `loading` stuck on true
1054
+
1055
+ `useCommand().dispatch` and `useCommandQuery().query` open by writing
1056
+ `loading = true` and `lastError = null`. A synchronous subscriber of either
1057
+ signal that throws (a raw `effect`, a `flush: 'sync'` watcher) throws out of
1058
+ that write, and the two writes sat before the `try`. The error escaped
1059
+ `dispatch`, the handler never ran, and `loading` stayed true until the next
1060
+ dispatch.
1061
+
1062
+ Both writes are now inside the `try`. The subscriber's error comes back as a
1063
+ failed `CommandResult`, `lastError` holds it, `loading` ends false, and the
1064
+ handler still does not run. A dispatch with no throwing subscriber is
1065
+ unchanged. `tests/rundispatch-throwing-subscriber.test.ts` has one test per
1066
+ write; both failed before. Cost: `dist/chamber.js` +4 bytes raw, -1 brotli;
1067
+ 39.9 against 38.8 ns per call in an interleaved A/B, no slower.
1068
+
1069
+ ### Fixed: the same throwing subscriber, at the other sites that write a signal before their bookkeeping
1070
+
1071
+ The shape above was found at five more sites. Each fix is the same move, the
1072
+ write goes inside the existing `try` or after the bookkeeping, and each has a
1073
+ test on real Vue `effect`s that failed first. A path with no throwing
1074
+ subscriber is unchanged at every one: same writes, same order.
1075
+
1076
+ - **`useVaporAsyncCommand().dispatch`.** The promise rejected with the
1077
+ subscriber's error and `loading` stayed true. It now resolves a failed
1078
+ `CommandResult`, `lastError` holds the error and `loading` ends false.
1079
+ `tests/vapor-async-throwing-subscriber.test.ts`. `dist/chamber-vapor.js`
1080
+ +4 bytes raw, +2 brotli; 120.4 against 120.4 ns per awaited call.
1081
+ - **`createFormBus`, `set()`.** A subscriber throwing on `values` left
1082
+ `errors`, `isDirty` and `isValid` unwritten: an invalid value read as valid.
1083
+ The three writes now happen whatever the `values` subscriber does. Still
1084
+ open: a subscriber that throws on `errors` or on `isDirty` skips the writes
1085
+ after it; covering those measured up to 5% slower per `set` and was not
1086
+ landed.
1087
+ - **`createFormBus`, `submit()`.** A subscriber throwing when `isValidating` or
1088
+ `isSubmitting` turned true left that flag true for good. `submit()` still
1089
+ rejects with the subscriber's error; the flag and `isBusy` now end false and
1090
+ the next `submit()` runs. `tests/form-throwing-subscriber.test.ts`.
1091
+ `dist/index.js` +64 bytes raw, +19 brotli.
1092
+ - **The router's commit.** A `currentRoute` subscriber that threw made a
1093
+ COMMITTED `push()` resolve `component_load_failed`, fire `onError` and skip
1094
+ `afterEach`, with the URL moved and the snapshot already the new page. The
1095
+ push now resolves `null`, `onCommit` and `afterEach` run, and the
1096
+ subscriber's error is logged as a post-commit hook's is.
1097
+ - **The router's background lanes.** A subscriber throwing when `isLoading`
1098
+ turned true on a query-only change, or when `isRevalidating` turned true,
1099
+ left that flag true for the life of the router, and the refetch or the
1100
+ refresh never ran. Both now run and lower their flag. The caller that
1101
+ triggered the write still gets the subscriber's error.
1102
+ `tests/router/engine-throwing-subscriber.test.ts`. `dist/router.js` +168
1103
+ bytes raw, +29 brotli; the commit statements measure the same, 37.5 against
1104
+ 37.3 ns, inside a push of about 6,500 ns.
1105
+ - **`createWsBridge`.** A subscriber of `connected` that threw skipped what
1106
+ followed the write: on open the queue was not flushed and `onConnect` did not
1107
+ run; on close `onDisconnect` did not run and the bridge never reconnected; in
1108
+ `disconnect()` in-flight requests were not failed and the socket was left
1109
+ open. All three now do their bookkeeping whatever the subscriber does; its
1110
+ error still leaves the handler. `tests/ws-bridge-throwing-subscriber.test.ts`.
1111
+ The full IIFE is 20 bytes SMALLER than before these fixes (39,483 raw,
1112
+ 12,234 brotli): two unreachable guards and a one-caller function in the same
1113
+ file paid for it.
1114
+ - **`useCommandHistory().undo()` and `redo()`, and the `history()` plugin.** A
1115
+ subscriber of `past`, `future`, `canUndo` or `canRedo` that threw while the
1116
+ stacks moved threw out of `undo()`: the undo handler never ran, the ledger
1117
+ had already moved the command to the redo stack, and the four signals were
1118
+ left half written. The history read "undone" over an application that was
1119
+ not. The move is now inside the `try` that guards the call, so a throw there
1120
+ is an undo that did not land: the stacks move back, all four signals agree,
1121
+ and the error is logged as a throwing undo handler's is.
1122
+ `tests/history-throwing-subscriber.test.ts`. `dist/settled.js` +2 bytes.
1123
+
1124
+ ## v1.25.0 - 2026-09-29
1125
+
1126
+ ### Changed (breaking): a failure's code is `owner:condition:subject`, and the owner comes from the wiring
1127
+
1128
+ Every failure the library, a plugin or a transport raises is a `BusError` whose
1129
+ `code` says who raised it, what went wrong from a closed vocabulary (`missing`,
1130
+ `already`, `invalid`, `refused`, `limited`, `timeout`, `lost`, `aborted`,
1131
+ `exceeded`, `failed`, `unexpected`, `unknown`), and what it is about. The owner
1132
+ is never typed at a site: the bus hands each party a `fail` bound to its owner
1133
+ (`core` inside the bus, a plugin's declared `id`, `transport`, `remote` for a
1134
+ backend), so no party can raise under another's name. Designed from RFC 3463's
1135
+ `class.subject.detail`, AIP-193's (domain, reason), OpenTelemetry's scope bound
1136
+ at `getTracer(name)` and Temporal's runtime-only failure kinds
1137
+ (docs/plan-failures-and-contract.md 4.5).
1138
+
1139
+ | Was | Is |
1140
+ |---|---|
1141
+ | `VC_CORE_NO_HANDLER` | `core:missing:handler` |
1142
+ | `VC_CORE_THROTTLED` | `core:limited:handler` (`register({ throttle })`), `throttle:limited:handler` (the plugin) |
1143
+ | `VC_CORE_REQUEST_TIMEOUT` | `core:timeout:request` |
1144
+ | `VC_CORE_MAX_DEPTH` / `VC_CORE_SEALED` / `VC_CORE_ABORTED` | `core:exceeded:depth` / `core:refused:bus` / `core:aborted:dispatch` |
1145
+ | `VC_CORE_BEFORE_CANCEL` | `core:refused:hook` |
1146
+ | `VC_PLUGIN_THREW` | `<plugin id>:failed:plugin` (`plugin:failed:plugin` when it declares none) |
1147
+ | `VC_PLUGIN_CIRCUIT_OPEN` / `VC_PLUGIN_RATE_LIMITED` | `circuitBreaker:limited:action` / `rateLimit:limited:action` |
1148
+ | `VC_VALIDATION_FAILED` | `validator:invalid:payload`, `validateSchemas:invalid:payload` |
1149
+ | `VC_TRANSPORT_TIMEOUT` / `_CLOSED` / `_QUEUE_FULL` | `transport:timeout:reply` / `transport:lost:reply` / `transport:lost:command` |
1150
+ | `VC_TRANSPORT_REDIRECT` / `_PROTOCOL` | `transport:refused:redirect` / `transport:lost:result` |
1151
+ | `VC_UNKNOWN` | `core:unknown:error`, no longer retryable |
1152
+ | `VC_TEST_*` (seven) | `test:missing:tap`, `test:already:instance`, `test:unexpected:version`, `test:lost:tap`, `test:invalid:files`, `test:missing:run`, `test:missing:coverage` |
1153
+ | a backend's failure (any path) | `remote:<condition of its status>:<its code>` (next entry) |
1154
+
1155
+ **Migrating:** compare the new strings. `RETRYABLE_CODES` is
1156
+ `RETRYABLE_CONDITIONS` (`limited`, `timeout`); new `ownerOf(e)` and
1157
+ `conditionOf(e)` read the parts, `failureCondition(e)` any failure's.
1158
+ `BusError.severity`, `BusError.emitter` and the `BusEmitter` type are gone:
1159
+ whoever logs a failure decides its level (the catalogue's `severity` is the
1160
+ suggestion), and the owner is in the code. A hand-built `new BusError('limited:quota', ...)`
1161
+ is the app's (`app:limited:quota`). A plugin declares its owner as `id`
1162
+ (`Object.assign(fn, { id: 'myPlugin' })`) and receives its `fail` as a third
1163
+ argument; a plugin that calls another passes its own `fail` on; a plugin called
1164
+ outside a bus in a test is wrapped with the new `wired(plugin)`, exported
1165
+ from `vapor-chamber` beside `createTestBus` (not from `vapor-chamber/vitest`,
1166
+ whose pure entry imports nothing from the library at runtime).
1167
+
1168
+ **What it closes.** A backend could make a permanent refusal retryable by
1169
+ sending one of six library code strings in a 2xx body (the residue stated in
1170
+ v1.23.0). The verdict is now the failure's condition: a backend's code is the
1171
+ subject, after the owner and the condition, so no string it sends can reach
1172
+ the verdict. `code` is read-only (a private field with a getter), so neither
1173
+ owner nor condition can be rewritten after the fact.
1174
+
1175
+ **What else changed with it.**
1176
+ - `toJSON()` is the failure as an RFC 9457 problem with the members it needs:
1177
+ `detail`, `code`, `action`, the context as extensions; `type` implicit. The
1178
+ transports' reader reads that shape back, so a failure crossing a worker, a
1179
+ channel or storage has one shape both ways.
1180
+ - The stack is decided once, by the constructor: only `failed` (a bug) captures
1181
+ one; every expected refusal is control flow and skips it.
1182
+ - The catalogue lost its `emitter` column (the owner is the code's first part);
1183
+ its `retryable` column is an outcome of the condition, asserted. The registry
1184
+ sweep now asks which site MINTS each code: every minted code has a row, and
1185
+ the eight declared-but-never-raised codes are listed with their reasons.
1186
+ - The test double mints as `core`, so a test reads the codes production does.
1187
+
1188
+ **Speed** (three-arm A/B, production, 11 rotated rounds, against the previous
1189
+ tree; the self-control arm is the noise band): the happy path is unchanged,
1190
+ 0.99-1.03x on bare, three-plugin, async, throttle/circuit/rateLimit passing and
1191
+ `retry` passing. Failures that used to capture a stack are 4-12x faster: no
1192
+ handler 0.086x, circuit open 0.097x, rate limit 0.101x, `retry` on a permanent
1193
+ refusal 0.26x. Refusals that already skipped it are within noise
1194
+ (1.02-1.07x). Locking `code` by `freeze` or a non-writable property cost 10-30%
1195
+ on those refusals and was not shipped.
1196
+
1197
+ **Size**, measured on the new IIFE target (next entry), against the old code on
1198
+ that same target: full +79 / +57, core +12 / +58, elements +12 / +46 raw /
1199
+ brotli; the budgets are set to the new, lower numbers. The Blade consumer
1200
+ bundle (ESM) measures 6,328 brotli against 6,260; its ceiling moves to 6,340,
1201
+ with the steps in `tests/esm-treeshake.test.ts`.
1202
+
1203
+ ### Changed (breaking): one wire contract, and retries by it
1204
+
1205
+ The answer to a command is the same shape on every wire
1206
+ (docs/plan-failures-and-contract.md 4.4): `{ state }` on success, `{ redirect }`
1207
+ for a navigation, an RFC 9457 problem on failure, with only the members it
1208
+ uses: `{ status, code, detail, errors? }`. A single command's failure is its
1209
+ non-2xx response; a batched result or a WebSocket frame is `{ id, ... }`. The
1210
+ client reads a problem as `remote:<condition of its status>:<code>`, the
1211
+ condition from a status table that says only what RFC 9110 says of a status,
1212
+ `detail` the message, everything else `error.context`. Removed, on both sides:
1213
+ `{ ok: false, error, code }`, the `ok` flag, `error` and `message` as message
1214
+ fields, `type` and `title`; `FormBus.setErrors` takes only
1215
+ `[{ pointer: '/payload/<field>', detail }]`, which the reference controller now
1216
+ sends for a `ValidationException` (it sent no field errors at all).
1217
+
1218
+ Retries read the same contract (`failureCondition(error)`); who retries, and
1219
+ what, is the next section. A redirect (`transport:refused:redirect`) is never
1220
+ re-sent, and a batch that answers without a command's result
1221
+ (`transport:lost:result`, it may have run) is re-sent only with a key - 1.24
1222
+ re-sent neither, and `tests/wire-contract.test.ts` pins both. The bridges'
1223
+ `retry` and `noRetry` options are gone (a declaration of "safe to send again"
1224
+ replaces them). The outbox keeps a record whose answer declared a
1225
+ `Retry-After` and flushes again when that wait is over; `dispose()` ends the
1226
+ wait. `serialize()`'s lanes, the retry's waits and the outbox's wait are one
1227
+ module (`src/scheduler.ts`).
1228
+
1229
+ No external consumers; the reference controller, the example servers, the
1230
+ guides and the tests moved with it (`tests/wire-contract.test.ts`).
1231
+
1232
+ ### Changed (breaking): the async bus retries, by class, and `retry()` / `retrying()` are gone
1233
+
1234
+ The async bus re-sends the call that produced the outcome - a handler, or a
1235
+ plugin declaring `transport: true` (the three bridges do) - so the plugins
1236
+ outside see one dispatch (docs/plan-shape.md 4). On by default:
1237
+
1238
+ - **By class.** Transient, always: `limited`, `timeout`, any failure declaring
1239
+ `retryIn` (a `Retry-After`), after that wait. Uncertain - `lost`, `failed`
1240
+ (a 500, a handler's throw), `unexpected`, `unknown`: the first attempt may
1241
+ have landed - only for an action declared idempotent or a command carrying
1242
+ an idempotency key. Never: a verdict, an abort, a depth bound, a library
1243
+ bug, the bus's own `register({ throttle })` refusal (a throttle drops
1244
+ repeats), an outbox replay (its scheduler owns it).
1245
+ - **Bounded.** 3 attempts; full jitter under `200ms * 2^n`, capped at 20s; a
1246
+ declared wait clamped to the same cap. `dispose()` and the dispatch's own
1247
+ signal end a wait (`core:aborted:dispatch`). A failure sent more than once
1248
+ carries `context.attempts`.
1249
+ - **Budgeted per bus** (gRPC A6): 10 tokens, a failure costs one, a success
1250
+ refunds a tenth, no retry below half.
1251
+ - **Declared.** `createAsyncCommandBus({ retry: { maxAttempts, baseDelay,
1252
+ maxDelay, actions: { 'cart*': 'idempotent', orderPay: false, searchRun: 2 } } })`;
1253
+ `retry: false` turns it off; `retry` on a schema action joins
1254
+ `retry.actions`. An action declared idempotent gets its dispatch id as its
1255
+ `Idempotency-Key`, one for every attempt.
1256
+
1257
+ Migration: `bus.use(retry(opts))` and `bus.use(retrying(bridge))` go; use the
1258
+ bridge alone and declare on the bus. 1.24's `retry()` re-sent a 500 and a
1259
+ handler's plain throw by default; now declare the action `'idempotent'` for
1260
+ that. `RETRYABLE_CONDITIONS` is `limited` and `timeout`, and the registry's
1261
+ `retryable` column follows it (`core:unknown:error` reads false). The IIFEs'
1262
+ `connect()` and `createApp()` take the bus's `retry` option in place of
1263
+ `retry: n`. `conditionOfStatus` and `failureCondition` moved into the core
1264
+ (still exported from the root; no longer from `http-errors`). New types from the root: `AsyncCommandBusOptions`,
1265
+ `RetryOptions`, `RetryDeclaration`, `AsyncSchemaCommandBusOptions`. Size: the IIFEs
1266
+ +114 to +136 B brotli, the policy now inside the async bus they create.
1267
+ The reference controller answers its `409 in_progress` (a key still running)
1268
+ with `Retry-After: 1`, so the bus's re-send comes back for the finished answer
1269
+ instead of settling as a conflict; the examples, guides and READMEs moved
1270
+ with it. `tests/retry-policy.test.ts`.
1271
+
1272
+ ### Changed: the IIFE bundles target 2022 browsers
1273
+
1274
+ The `<script>`-tag builds target Chrome 100, Firefox 100 and Safari 16 (was
1275
+ Chrome 80, Firefox 75, Safari 13). Below that floor `?.`, `??`, class fields and
1276
+ private fields were lowered into helpers in every bundle; above it all three are
1277
+ native. Core IIFE 27,164 -> 25,923 raw, 7,992 -> 7,727 brotli, with the change
1278
+ above included. A page on an older browser keeps working with the previous
1279
+ release's IIFE. The ESM build (what apps bundle) was already es2022.
1280
+
1281
+ ### Changed: the async bus's `request()` settles one promise
1282
+
1283
+ It raced three (the dispatch, the timer, the caller's abort); it now settles
1284
+ one through a single `done`, as the sync bus's `request()` does, and removes
1285
+ its dedup entry only while that entry is still its own. Measured on a request
1286
+ whose responder answers at once: 2,958 -> 2,293 ns. Settled results are
1287
+ unchanged (`tests/request-dispose-ab.test.ts`).
1288
+
1289
+ ### Changed (breaking): the HTTP client's `safe` helpers return the contract's problem
1290
+
1291
+ `http.safe.*` resolved `error` to the response body as it came, or to
1292
+ `{ message, code }` built for the occasion. It is now the RFC 9457 problem the
1293
+ wire contract uses (`ProblemDetails`, now exported from `http-errors`): the
1294
+ backend's body with its `status` added, `detail` its sentence or
1295
+ `HTTP <status>`; with no response (a timeout, an abort, the network)
1296
+ `{ detail }` only. Read `error.detail` where you read `error.message`.
1297
+
1298
+ `HttpError.status` is gone: it always equalled `response.status`, which the
1299
+ HttpClient contract already guarantees a failure carries. Read
1300
+ `error.response?.status`; `failureCondition` and the outbox's verdict read it
1301
+ there too, so a custom client's error is judged by its `response`, not by a
1302
+ bare `status` field.
1303
+
1304
+ ### Changed (breaking, types only): `Plugin` is typed for either bus
1305
+
1306
+ `Plugin` declared `next: () => CommandResult` while installing on the async
1307
+ bus, where `next()` may be a promise; the built-in plugins escaped it with
1308
+ `as unknown as Plugin` and `const plugin: any`. It is now generic:
1309
+ `<R extends CommandResult | Promise<CommandResult>>(cmd, next: () => R, fail) => R | CommandResult`,
1310
+ a sync plugin on the sync bus and an async one on the async bus. A plugin that
1311
+ reads the result settles it through `onSettled` (now exported, with
1312
+ `MaybeAsyncResult`); reading `.ok` straight off `next()` no longer compiles as
1313
+ a `Plugin`, which is the defect it would be on the async bus. A plugin for one
1314
+ bus only is a `SyncPlugin` or an `AsyncPlugin`; `PluginParts` is what every
1315
+ plugin may carry (`dispose`, `id`). The async bus has one `use(AsyncPlugin)`
1316
+ (a `Plugin` fits it), `BaseBus.use` takes a `Plugin` rather than `any`, and
1317
+ the IIFEs' `connect({ plugins })` takes `AsyncPlugin[]`. No cast is left in the
1318
+ built-in plugins. It found the defect in two example plugins and in the
1319
+ README's timing example, all corrected.
1320
+
1321
+ ### Changed: every `limited` refusal declares when to come back
1322
+
1323
+ `circuitBreaker()` and `rateLimit()` refuse with `context.retryIn`, as a
1324
+ throttle and a `Retry-After` already did: the time left until the circuit goes
1325
+ half-open, and until the oldest dispatch leaves the window. `retry()` waits
1326
+ that time instead of spending its attempts on refusals, and the outbox flushes
1327
+ again when it is over, whatever order the plugins are installed in. No new
1328
+ option and no shared state: the wait travels on the failure, where the
1329
+ contract already carries it.
1330
+
1331
+ ### Removed (breaking): `agentOrigin()` from `vapor-chamber/mcp`
1332
+
1333
+ A no-op since v1.12.0, deprecated and "kept for one release": the core stamps
1334
+ `meta.origin === 'agent'` on MCP-driven dispatches itself. Delete the
1335
+ `bus.use(agentOrigin(), ...)` line. Its tests now assert the core's stamp.
1336
+
1337
+ ### Added: `failureCondition` from the package root
1338
+
1339
+ A custom `isRetryable` reads the same condition the default rule does
1340
+ (`failureCondition(error)`: a library failure's own condition, an HTTP
1341
+ response's status through the status table, a timeout, an abort, `lost`),
1342
+ instead of matching statuses inside `error.message`, which is what
1343
+ `examples/feature-retry.ts` taught; it now reads the condition (`conditionOf`)
1344
+ and leaves the re-sending to the async bus.
1345
+
1346
+ ### Changed (breaking, reference controller): an `Idempotency-Key` that is not a String is ignored
1347
+
1348
+ The PHP controller accepted a raw, unquoted key "from a client that predates
1349
+ the quoting". A value that is not a Structured Field String fails to parse,
1350
+ and RFC 9651 ignores a field that fails to parse: the request runs with no
1351
+ key.
1352
+
1353
+ ### Fixed: the examples teach the current shapes
1354
+
1355
+ Read in full, every example and companion: the Laravel app and backend, the
1356
+ Astro, island-cart, Vapor SFC and router demos, the patterns and the feature
1357
+ snippets. Old shapes replaced (`{ ok }` answers, `quantity`, a composable from
1358
+ the package root in a Vue component, `useCommand` inside React, statuses read
1359
+ out of `error.message`, a hand-rolled retry, uncoded plugin refusals, the
1360
+ PHP actions validating the target so a field error pointed at a payload field
1361
+ that did not exist); pressed buttons use `aria-disabled` instead of dropping
1362
+ keyboard focus with `disabled`; outcomes reach a live region or `announce()`;
1363
+ text colours meet 4.5:1 (WCAG 1.4.3); the static example server no longer
1364
+ crashes on a malformed URL.
1365
+
1366
+ ### Fixed: the sprinkled-Blade example is accessible
1367
+
1368
+ It disabled the focused button while busy, which sends a keyboard user's focus
1369
+ to `<body>` (the defect `v-vc-command` avoids), wrote the backend's message
1370
+ into `innerHTML`, and its status line was not a live region. It now marks the
1371
+ button `aria-disabled` and ignores a press in flight, writes text, and
1372
+ announces the outcome (`role="status"`).
1373
+
1374
+ ### Changed: source comments carry the current reason, not the release history
1375
+
1376
+ Release-by-release logs in module headers, "used to" narratives and version
1377
+ tags on comments are rewritten as the rule each one established, keeping the
1378
+ measurement or the test that pins it; the history stays in this file.
1379
+
1380
+ ### Fixed (router): typed params match only their type
1381
+
1382
+ A row's `params: { id: 'int' }` compiled to `[^/]+` and cast with `parseInt`,
1383
+ so `/products/7x` and `/products/7.5` resolved product 7 and `/products/abc`
1384
+ handed an `int` param over as the string `'abc'`; a `bool` read anything but
1385
+ `1`/`true` as false. A typed param with no regex of its own now matches only
1386
+ its type (`int`: `-?\d+`, `bool`: `1|0|true|false`, case-insensitive like the
1387
+ rest of matching), so a URL that does not fit falls through to the next row.
1388
+
1389
+ ### Fixed (router): query params and pagination hand out values, not the location
1390
+
1391
+ - `decodeQueryParam` returned a present array param by reference, so a
1392
+ component's `tags.value.push(...)` rewrote the committed location's query
1393
+ while the URL stayed as it was. It returns a copy, as it already did for a
1394
+ declared default.
1395
+ - `usePagination()`'s `page` is a number even on a route that declares no
1396
+ `page` param (it read `?page=2` as `'2'`); the route's own declaration still
1397
+ wins.
1398
+
1399
+ ### Fixed (router): `revalidateRoutes` never fails the command it follows
1400
+
1401
+ A revalidation map naming a record outside the current chain threw inside the
1402
+ plugin after the mutation had succeeded: on a sync bus the command came back
1403
+ as `plugin:failed:plugin`, on an async bus it was an unhandled rejection. The
1404
+ coded `unknown_route_name` error now goes to `onError` (default
1405
+ `console.error`) and the command's result passes through. The plugin declares
1406
+ `id: 'revalidateRoutes'`.
1407
+
1408
+ ### Changed (breaking, router): a routes endpoint answers by the wire contract
1409
+
1410
+ `unwrapRoutesPayload` (the `{ url }` and `{ inline }` route sources) read the
1411
+ old `{ ok, state, error }` envelope. It accepts a bare payload, `{ state }`,
1412
+ or `{ problem }` - the last as a `routes_load_failed` carrying the problem's
1413
+ `detail`.
1414
+
1415
+ ### Fixed: `authGuard` refuses with a code
1416
+
1417
+ Its refusal was a plain `Error`, the one library refusal without a code, so
1418
+ `retry()` re-sent an unauthenticated command and a circuit breaker counted it
1419
+ as a failing server. It is now `authGuard:refused:action`, raised through the
1420
+ plugin's own `fail` (`id: 'authGuard'`), and in `ERROR_CODE_REGISTRY`. A
1421
+ protected prefix matched the exact action name twice over; once is enough.
1422
+
1423
+ ### Fixed: a backend's failure opens the circuit breaker
1424
+
1425
+ `circuitBreaker()` skipped every `failed` condition to ignore a plugin that
1426
+ threw, so a backend's 5xx (`remote:failed:<code>`) never counted and the
1427
+ circuit never opened on the failure it exists for. It now skips only a party's
1428
+ own throw, by the rule `retry()` already used (one internal predicate for
1429
+ both). A plugin that throws still neither counts nor resets the run.
1430
+
1431
+ ### Fixed: a history cleared while an undo is in flight stays cleared
1432
+
1433
+ If the server refused an undo that was still pending when `clear()` ran, the
1434
+ revert pushed the command back into the undo stack of a history the user had
1435
+ just cleared. A revert now moves a command only if it is still where the undo
1436
+ put it. Found by the coverage pass; `tests/history-undo-lands.test.ts` (failed
1437
+ on the old code).
1438
+
1439
+ ### Fixed: `retry()` no longer builds an Error on every successful dispatch
1440
+
1441
+ It opened each dispatch with `_errResult(new Error('No attempts made'))`, a
1442
+ stack capture on every call, success included, for a result that could never
1443
+ be returned (at least one attempt is guaranteed). Measured: `retry` alone
1444
+ 48,857 ns per successful dispatch against 5,635 without it, 6,031 once fixed; a
1445
+ realistic chain (validator, idempotent, serialize, circuitBreaker, retry)
1446
+ halved, 69,001 to 34,135 ns. `tests/retry-happy-path.test.ts`.
1447
+
1448
+ ### Fixed: `register()`'s cleanup removes only what it registered
1449
+
1450
+ It deleted whatever handler (and undo handler) held the action name when it
1451
+ ran. KeepAlive with a `max` sets up the new page and THEN evicts the old one,
1452
+ so the evicted page's cleanup deleted the handler of the page on screen:
1453
+ `dispatch` answered `core:missing:handler`. The whitepaper's rule ("the newest
1454
+ handler always wins regardless of teardown") was drawn from HMR, where the
1455
+ order is the other way round. `createTestBus()` had the same cleanup and now
1456
+ matches. `tests/register-ownership.test.ts` (5, all failed on the old code,
1457
+ including a real Vapor KeepAlive).
1458
+
1459
+ ### Fixed: a store held outside any scope is no longer disposed under its owner
1460
+
1461
+ `docs/store.md` says the last holder out disposes and an unscoped caller owns
1462
+ `$dispose()`, but only scoped holders were counted: a module-level store was
1463
+ disposed by the first component that joined and left, its actions answered
1464
+ `ok: false`, and the next `useStore` built a fresh store with its state reset.
1465
+ A call outside any scope now pins the store until its owner disposes it.
1466
+ `tests/store-unscoped-holder.test.ts`.
1467
+
1468
+ ### Fixed: `useCommand().register` follows KeepAlive
1469
+
1470
+ `useCommandHistory` and `useCommandError` paused for a KeepAlive deactivation;
1471
+ `useCommand`, the composable that registers handlers, did not, so after A -> B
1472
+ -> A the cached B's handler answered while A was on screen. A deactivated page
1473
+ now releases its handlers and takes them back when activated (register()'s
1474
+ cleanup removes only its own, above). Listeners (`on()`) are unchanged: whether
1475
+ a cached page keeps listening is still an open decision.
1476
+ `tests/usecommand-keepalive.test.ts`.
1477
+
1478
+ ### Fixed: errors match the library's own catalogue
1479
+
1480
+ `validator()` refused with a plain `Error` (no code) although the catalogue
1481
+ declares a validation code for per-action validation; it now refuses with
1482
+ `validator:invalid:payload`, the message still the rule's own. One consequence: `retry()`'s
1483
+ default rule treated the uncoded error as retryable (no status), so a refused
1484
+ validation was retried; a coded permanent refusal is not. And three sites built
1485
+ the throttled and aborted refusals with a severity the catalogue contradicted
1486
+ (severity has since left the failure object; see the first entry). `tests/errors-match-catalogue.test.ts`.
1487
+
1488
+ ### Fixed: the `Idempotency-Key` header is a Structured Field String
1489
+
1490
+ The draft this bridge follows makes the value a quoted, escaped String (RFC
1491
+ 9651); it was sent raw. The key is the command plus its target as JSON, so it
1492
+ was unquoted and unescaped, and a target with any character outside Latin-1
1493
+ (an emoji, CJK text) made a value `Headers` refuses outright: the request never
1494
+ left. Now the key is percent-encoded (the web's own encoding, UTF-8) and
1495
+ quoted: only unreserved ASCII and `%XX`, so an sf-string with nothing left to
1496
+ escape, reversible, so no two keys collide. The reference controller unquotes
1497
+ and `rawurldecode`s it back to the exact key, the same one the batching bridge
1498
+ sends in the body, and still accepts a raw key from an older client. Two
1499
+ encodings were measured and dropped first: hand escaping plus `%uXXXX` (+63 B
1500
+ brotli on the Blade consumer bundle), and the same in one regex pass (+87).
1501
+ **Upgrade order: the backend first**, or a quoted key misses a cache written
1502
+ under the raw one for up to the replay window. `tests/idempotency-header.test.ts`;
1503
+ `docs/integrations/laravel.md` updated, and its "line for line" snippet
1504
+ corrected where it had drifted from the controller (`fail` -> `problem`, the
1505
+ redirect-aware success body).
1506
+
1507
+ ### Changed: error advice is DEV-only; one throttle gate
1508
+
1509
+ Production error messages state the fact; the fix sentence is DEV's, and the
1510
+ catalogue (`ERROR_CODE_REGISTRY`, each code's `fix`) is where a production
1511
+ reader finds it (docs/plan-failures-and-contract.md, settled item 5). Touched:
1512
+ `core:refused:bus`, `core:exceeded:depth`, `core:timeout:request`,
1513
+ `core:missing:handler` (production is `No handler registered for "x".`), the
1514
+ naming-pattern violation, `createVaporChamberApp`'s VDOM tail and `persist`'s
1515
+ "stale after a deploy". Match on `.code`, never on `.message`; development
1516
+ builds read as before. `vueDetectionHint` still ships: it is a diagnosis, and a
1517
+ `<script>`-tag page only ever runs a production IIFE.
1518
+
1519
+ `register(action, h, { throttle })` and the `throttle()` plugin were the same
1520
+ gate written twice; both use one now, so the plugin also skips the stack
1521
+ capture on its rejected path, the hot one. The refusal reads `"x" throttled.
1522
+ Retry in Nms.` from either (it said `Handler "x"` or `Action "x"`).
1523
+
1524
+ These paid for this release's fixes without a raise (measured before the
1525
+ failure shape and the IIFE target in the first two entries): all three IIFE
1526
+ budgets went DOWN (full 41,172 / 12,303, core 27,164 / 7,992, elements 28,928 / 8,499
1527
+ raw / brotli) and the Blade consumer bundle measures 6,260 brotli, below
1528
+ v1.24.0's 6,363; its ceiling locks at 6,280.
1529
+
1530
+ ### Added: `announce()` - status messages for assistive technology; `vc-error` made semantic
1531
+
1532
+ `announce(message, { assertive })` says something to a screen reader without
1533
+ moving focus (WCAG 4.1.3), through ONE shared pair of live regions per document
1534
+ (polite, assertive), created on first use: the pattern of Angular CDK's
1535
+ LiveAnnouncer and React Aria's `announce()`. A repeated message is heard again.
1536
+ `setAnnouncer(fn)` lets an app take the announcing over (its own status bar, its
1537
+ own words); `setAnnouncer(null)` hands it back. Exported from the root.
1538
+
1539
+ Two features now speak through it instead of on their own:
1540
+
1541
+ - **`v-vc-command` failures.** `vc-error` stays for styling, but a class is
1542
+ invisible to a screen reader. A failed dispatch now also announces the
1543
+ failure's message assertively, focus left where it is. The words are the
1544
+ failure's own: since 1.24 a backend problem's `detail`, already localized.
1545
+ An app that wants other words uses `setAnnouncer`.
1546
+ - **The router's page changes** use the shared assertive region; the router no
1547
+ longer creates a region of its own.
1548
+
1549
+ `tests/a11y-announce.test.ts` (7), `tests/browser/command-errors.browser.test.ts`
1550
+ (3; 2 failed on the old code). Size: `./directives` +0.2 KB brotli, `./router`
1551
+ unchanged, core unchanged, IIFEs unchanged.
1552
+
1553
+ ### Fixed: an undo the server refuses no longer reads as "undone"
1554
+
1555
+ `useCommandHistory()` and the `history()` plugin moved their stacks before an
1556
+ undo or redo and never looked at the outcome. On an async bus, an undo the
1557
+ server refused left the history saying "undone": the command was no longer
1558
+ undoable, and a redo was offered for a change that never happened. For a
1559
+ screen-reader user, who cannot see that nothing changed, that is
1560
+ indistinguishable from success (WCAG 3.3.4).
1561
+
1562
+ Now the stacks still move first, so anything observing the call sees what it
1563
+ always saw, and move BACK when the undo or redo does not land: a throw, a
1564
+ rejection, or a failed `CommandResult`, returned or resolved. While one is in
1565
+ flight, another press does nothing. The API is unchanged: `undo()` and
1566
+ `redo()` return the command at once, and a synchronous local undo behaves
1567
+ exactly as before. `tests/history-undo-lands.test.ts` (8; 6 failed on the old
1568
+ code). A first cut that moved the stacks only AFTER the call broke the pinned
1569
+ island-cart wiring (a listener on a redo's re-dispatch reads the redone state),
1570
+ which is why the order is move-then-revert.
1571
+
1572
+ **One implementation.** The two histories each carried their own stacks,
1573
+ recording rule, undo and redo, so this defect existed twice. They now wrap one
1574
+ internal ledger (`src/ledger.ts`) and differ only where they genuinely differ:
1575
+ where a command is recorded, what else is skipped, how the stacks are exposed.
1576
+ The `history()` plugin's `bus` option is now typed `CommandBus |
1577
+ AsyncCommandBus`, what it already worked with at runtime.
1578
+
1579
+ **Size:** full IIFE +787 raw / +278 brotli (budget raised, measured steps in
1580
+ `scripts/check-size.mjs`); core and elements IIFEs unchanged; ESM import
1581
+ everything 24.9 KB brotli either way. `tests/history-redo-ab.test.ts` now derives
1582
+ a ledger per arm, so its reverted baseline still reaches the code it times.
1583
+
1584
+ ### Added: FormBus takes the server's errors, and says what an element needs to make them accessible
1585
+
1586
+ Most real form errors come from the server, not from client rules, and a form
1587
+ had no way to show them: `onSubmit` throwing a 422 made `submit()` reject, and
1588
+ its field errors were lost. Now:
1589
+
1590
+ - `form.setErrors(errors)` takes an RFC 9457 problem's `errors` list
1591
+ (`{ pointer, detail }`; `/email`, `#/payload/email` and `/address/city` all
1592
+ resolve to their field) or a Laravel field map (`{ email: [...] }`). Each
1593
+ error shows at once and clears when its own field changes; rule errors keep
1594
+ working; `reset()` clears them; `submit()` clears them before validating,
1595
+ since the server decides again.
1596
+ - `submit()` lands a field-carrying failure itself: when `onSubmit` throws an
1597
+ error whose problem has `errors`, the fields are marked and it resolves
1598
+ `false`. Any other failure still rejects, as before.
1599
+ - `form.aria(field)` gives `aria-invalid` and `aria-describedby`, set only while
1600
+ the error is shown; `form.errorId(field)` is the message element's id, unique
1601
+ per form (two forms on a page cannot produce duplicate ids);
1602
+ `form.firstInvalid()` names the field to focus after a failed submit.
1603
+
1604
+ The messages are the backend's, already localized; the form holds no strings.
1605
+ `tests/form-a11y.test.ts` (11; 10 failed on the old code), and in real Chromium
1606
+ `tests/browser/form-errors.browser.test.ts`: Playwright's role engine reads the
1607
+ field as invalid, described by the server's message.
1608
+
1609
+ ### Fixed: a `role="button"` element with `v-vc-command` works from the keyboard
1610
+
1611
+ A `<div role="button">` carrying the directive answered pointer clicks only:
1612
+ not focusable, and Enter and Space did nothing (WCAG 2.1.1). A native
1613
+ `<button>` gets both from the platform; a role does not. The directive now gives
1614
+ a non-native `role="button"` element what the ARIA Authoring Practices ask of a
1615
+ button: `tabindex="0"` when it has none (removed again at teardown; a tabindex
1616
+ the app set is kept), Enter activates on key down, Space on key up with its page
1617
+ scroll prevented. Activation is `el.click()`, so it takes the same path as a
1618
+ pointer press. A key the app already handled (`defaultPrevented`) is left
1619
+ alone, and a native button still dispatches once per key.
1620
+ `tests/browser/command-keyboard.browser.test.ts` (6, real key presses through
1621
+ Playwright; 3 failed on the old code).
1622
+
1623
+ ### Added: route changes reach assistive technology
1624
+
1625
+ A full page load announces the new page to a screen reader and restarts focus;
1626
+ a client-side navigation did neither. The router now announces each one in an
1627
+ assertive live region (`document.title`, else the first `<h1>`, else the path),
1628
+ skipping the initial load and query-only changes, and removes the region on
1629
+ `destroy()`. New options: `announce` (default `true`; `false`, or a function
1630
+ returning the text) and `focusOnNavigate` (a selector for a small element the
1631
+ app provides, focused after each navigation; made focusable only if it is not).
1632
+ Rules from Next.js's route announcer and Gatsby's user testing with disabled
1633
+ users. `tests/router/announce.test.ts` (7), and in real Chromium
1634
+ `tests/browser/route-announce.browser.test.ts`.
1635
+
1636
+ **Behaviour change:** after the first navigation, the document gains the shared
1637
+ visually hidden live regions (`<div data-vc-announcer="assertive">`, see "Added:
1638
+ `announce()`" below); the router no longer creates a region of its own. Pass
1639
+ `announce: false` to opt out.
1640
+
1641
+ ### Added: `aria-current="page"` on the current link
1642
+
1643
+ `stampActiveLinks` now sets `aria-current="page"` on the exactly-active anchor
1644
+ and removes it when the route moves on; an `aria-current` the page set for
1645
+ another purpose (`"step"`) is left alone. Section links that are only a prefix
1646
+ match keep `data-active` and are not marked, since ARIA asks for one current
1647
+ element per set. `MenuItem.exactActive` and `Breadcrumb.current` now say they
1648
+ are the fields to bind `aria-current` to; `MenuItem.active` says it is not.
1649
+
1650
+ ### Fixed: `v-vc-command` no longer takes keyboard focus away
1651
+
1652
+ While a dispatch was in flight the directive set `disabled` on its button.
1653
+ HTML's focus fixup rule answers that by moving focus to `<body>`, so a keyboard
1654
+ user who pressed Enter on a command button was sent back to the top of the page
1655
+ on every dispatch, and focus did not return when the button was re-enabled.
1656
+ Measured in real Chromium, production build, with the new browser runner
1657
+ (`npm run test:browser`, `tests/browser/command-focus.browser.test.ts`): during
1658
+ the dispatch `{ disabled: true, focused: false }`, after it
1659
+ `{ disabled: false, focused: false }`. A zero-delay timeout read the opposite,
1660
+ because the rule runs during the next rendering update.
1661
+
1662
+ Now a button in flight (a `<button>`, or any element with `role="button"`) gets
1663
+ `aria-disabled="true"`, restored to its previous value when it lands. A
1664
+ `role="button"` element used to get nothing at all in flight. Links are left as
1665
+ they were: their press is navigation. Not `aria-busy`: it tells assistive technology to hold off
1666
+ on the element's content, and a button's content is its accessible name. What `disabled` also did is
1667
+ kept by hand and pinned by the same file, each measured on the old code first:
1668
+
1669
+ - the press that starts the dispatch does not submit a form (`preventDefault`);
1670
+ - no further press reaches any listener, the app's own included, until the
1671
+ dispatch lands (a capture listener, attached only while in flight, so
1672
+ `.delegate` still attaches nothing per element at rest);
1673
+ - the button works again once the dispatch lands.
1674
+
1675
+ **Behaviour changes to check:**
1676
+
1677
+ - CSS keyed on `:disabled` to show a button working stops matching during a
1678
+ dispatch. Use `.vc-loading` (unchanged). Announcing progress in words is the
1679
+ app's (a live region, in its own language).
1680
+ - A `disabled` the app sets is no longer cleared when a dispatch lands: the old
1681
+ `finally` set `disabled = false` whatever the app had set.
1682
+ - Do not bind `:aria-disabled` on the element; `:disabled` is
1683
+ now safe to bind (whitepaper, "A bound update overwrites what `v-vc-command`
1684
+ writes by hand").
1685
+
1686
+ `tests/directives-vapor-fixture.test.ts`'s identity test now also asserts the
1687
+ in-flight guard comes off after every dispatch.
1688
+
5
1689
  ## v1.24.0 - 2026-09-24
6
1690
 
7
1691
  ### Added: RFC 9457 problem documents are read on every path