vapor-chamber 1.25.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 (234) hide show
  1. package/CHANGELOG.md +1121 -2
  2. package/README.md +109 -50
  3. package/ROADMAP.md +13 -13
  4. package/dist/LICENSE.txt +8 -2
  5. package/dist/blade.js +6 -6
  6. package/dist/blade.js.map +1 -1
  7. package/dist/chamber-vapor.d.ts +10 -0
  8. package/dist/chamber-vapor.d.ts.map +1 -1
  9. package/dist/chamber-vapor.js +5 -4
  10. package/dist/chamber-vapor.js.map +1 -1
  11. package/dist/chamber.d.ts +6 -31
  12. package/dist/chamber.d.ts.map +1 -1
  13. package/dist/chamber.js +139 -98
  14. package/dist/chamber.js.map +1 -1
  15. package/dist/command-bus.d.ts +75 -80
  16. package/dist/command-bus.d.ts.map +1 -1
  17. package/dist/command-bus.js +209 -150
  18. package/dist/command-bus.js.map +1 -1
  19. package/dist/dict.d.ts +1 -1
  20. package/dist/dict.js.map +1 -1
  21. package/dist/directives.d.ts +9 -1
  22. package/dist/directives.d.ts.map +1 -1
  23. package/dist/directives.js +2 -2
  24. package/dist/directives2.js +12 -4
  25. package/dist/directives2.js.map +1 -1
  26. package/dist/failure.d.ts +89 -0
  27. package/dist/failure.d.ts.map +1 -0
  28. package/dist/failure.js +70 -0
  29. package/dist/failure.js.map +1 -0
  30. package/dist/fast-lane.d.ts +4 -3
  31. package/dist/fast-lane.d.ts.map +1 -1
  32. package/dist/fast-lane.js +29 -18
  33. package/dist/fast-lane.js.map +1 -1
  34. package/dist/form.d.ts.map +1 -1
  35. package/dist/freeze.js.map +1 -1
  36. package/dist/http-cache.d.ts +21 -2
  37. package/dist/http-cache.d.ts.map +1 -1
  38. package/dist/http-errors.d.ts +26 -2
  39. package/dist/http-errors.d.ts.map +1 -1
  40. package/dist/http.d.ts +55 -36
  41. package/dist/http.d.ts.map +1 -1
  42. package/dist/http.js +221 -109
  43. package/dist/http.js.map +1 -1
  44. package/dist/iife-core.js +1 -1
  45. package/dist/iife-elements.d.ts +6 -0
  46. package/dist/iife-elements.d.ts.map +1 -1
  47. package/dist/iife-elements.js +1 -1
  48. package/dist/iife-elements.js.map +1 -1
  49. package/dist/iife.js +2 -2
  50. package/dist/index.d.ts +7 -4
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +491 -86
  53. package/dist/index.js.map +1 -1
  54. package/dist/keys.js +11 -9
  55. package/dist/keys.js.map +1 -1
  56. package/dist/ledger.d.ts +4 -0
  57. package/dist/ledger.d.ts.map +1 -1
  58. package/dist/ledger.js +20 -6
  59. package/dist/ledger.js.map +1 -1
  60. package/dist/library-names.d.ts +15 -0
  61. package/dist/library-names.d.ts.map +1 -0
  62. package/dist/library-names.js +13 -0
  63. package/dist/library-names.js.map +1 -0
  64. package/dist/mcp.d.ts +1 -1
  65. package/dist/mcp.js +4 -3
  66. package/dist/mcp.js.map +1 -1
  67. package/dist/outbox.d.ts +30 -14
  68. package/dist/outbox.d.ts.map +1 -1
  69. package/dist/outbox.js +24 -15
  70. package/dist/outbox.js.map +1 -1
  71. package/dist/plugins-core.d.ts +3 -2
  72. package/dist/plugins-core.d.ts.map +1 -1
  73. package/dist/plugins-core.js +32 -25
  74. package/dist/plugins-core.js.map +1 -1
  75. package/dist/plugins-extra.d.ts +3 -1
  76. package/dist/plugins-extra.d.ts.map +1 -1
  77. package/dist/plugins-io.d.ts.map +1 -1
  78. package/dist/plugins-io.js +2 -0
  79. package/dist/plugins-io.js.map +1 -1
  80. package/dist/plugins-schema.d.ts.map +1 -1
  81. package/dist/plugins-schema.js +7 -8
  82. package/dist/plugins-schema.js.map +1 -1
  83. package/dist/poll-with.d.ts +38 -0
  84. package/dist/poll-with.d.ts.map +1 -0
  85. package/dist/reactive.js +1 -1
  86. package/dist/reactive.js.map +1 -1
  87. package/dist/router/blade.d.ts.map +1 -1
  88. package/dist/router/dom.d.ts.map +1 -1
  89. package/dist/router/engine.d.ts +6 -2
  90. package/dist/router/engine.d.ts.map +1 -1
  91. package/dist/router/errors.d.ts +25 -16
  92. package/dist/router/errors.d.ts.map +1 -1
  93. package/dist/router/index.d.ts +5 -4
  94. package/dist/router/index.d.ts.map +1 -1
  95. package/dist/router/index.js +2 -2
  96. package/dist/router/loaders.d.ts +12 -3
  97. package/dist/router/loaders.d.ts.map +1 -1
  98. package/dist/router/remote.js +1 -1
  99. package/dist/router/table.d.ts +2 -1
  100. package/dist/router/table.d.ts.map +1 -1
  101. package/dist/router/types.d.ts +2 -1
  102. package/dist/router/types.d.ts.map +1 -1
  103. package/dist/router/vapor.d.ts +1 -1
  104. package/dist/router/vapor.d.ts.map +1 -1
  105. package/dist/router/vapor.js +11 -8
  106. package/dist/router/vapor.js.map +1 -1
  107. package/dist/router/vdom.js +1 -1
  108. package/dist/router/vdom.js.map +1 -1
  109. package/dist/router-fetch/index.d.ts.map +1 -1
  110. package/dist/router-fetch/index.js +3 -2
  111. package/dist/router-fetch/index.js.map +1 -1
  112. package/dist/router.js +142 -89
  113. package/dist/router.js.map +1 -1
  114. package/dist/schema.d.ts +1 -3
  115. package/dist/schema.d.ts.map +1 -1
  116. package/dist/settled.d.ts.map +1 -1
  117. package/dist/settled.js +1 -1
  118. package/dist/settled.js.map +1 -1
  119. package/dist/shared-bus.d.ts +46 -0
  120. package/dist/shared-bus.d.ts.map +1 -0
  121. package/dist/shared-bus.js +24 -0
  122. package/dist/shared-bus.js.map +1 -0
  123. package/dist/ssr.d.ts +2 -2
  124. package/dist/ssr.d.ts.map +1 -1
  125. package/dist/ssr2.js +5 -6
  126. package/dist/ssr2.js.map +1 -1
  127. package/dist/store/core.d.ts +4 -0
  128. package/dist/store/core.d.ts.map +1 -0
  129. package/dist/store/core.js +34 -0
  130. package/dist/store/core.js.map +1 -0
  131. package/dist/store-base.d.ts +104 -0
  132. package/dist/store-base.d.ts.map +1 -0
  133. package/dist/store-base.js +217 -0
  134. package/dist/store-base.js.map +1 -0
  135. package/dist/store-core.d.ts +4 -0
  136. package/dist/store-core.d.ts.map +1 -0
  137. package/dist/store.d.ts +22 -55
  138. package/dist/store.d.ts.map +1 -1
  139. package/dist/store.js +39 -80
  140. package/dist/store.js.map +1 -1
  141. package/dist/testing.d.ts.map +1 -1
  142. package/dist/transitions/vapor.d.ts +102 -0
  143. package/dist/transitions/vapor.d.ts.map +1 -0
  144. package/dist/transitions/vapor.js +107 -0
  145. package/dist/transitions/vapor.js.map +1 -0
  146. package/dist/transitions.d.ts +8 -1
  147. package/dist/transitions.d.ts.map +1 -1
  148. package/dist/transitions2.js +2 -1
  149. package/dist/transitions2.js.map +1 -1
  150. package/dist/transports.d.ts +18 -7
  151. package/dist/transports.d.ts.map +1 -1
  152. package/dist/transports2.js +100 -97
  153. package/dist/transports2.js.map +1 -1
  154. package/dist/vapor-chamber-core.iife.js +553 -301
  155. package/dist/vapor-chamber-core.iife.js.map +1 -1
  156. package/dist/vapor-chamber-core.iife.min.js +1 -1
  157. package/dist/vapor-chamber-elements.iife.js +601 -336
  158. package/dist/vapor-chamber-elements.iife.js.map +1 -1
  159. package/dist/vapor-chamber-elements.iife.min.js +1 -1
  160. package/dist/vapor-chamber.iife.js +815 -451
  161. package/dist/vapor-chamber.iife.js.map +1 -1
  162. package/dist/vapor-chamber.iife.min.js +1 -1
  163. package/dist/vapor.d.ts +15 -1
  164. package/dist/vapor.d.ts.map +1 -1
  165. package/dist/vapor.js +6 -3
  166. package/dist/vapor.js.map +1 -1
  167. package/dist/vite-hmr.d.ts +33 -5
  168. package/dist/vite-hmr.d.ts.map +1 -1
  169. package/dist/vite-hmr.js +14 -7
  170. package/dist/vite-hmr.js.map +1 -1
  171. package/dist/vitest.d.ts.map +1 -1
  172. package/dist/vitest.js +1 -0
  173. package/dist/vitest.js.map +1 -1
  174. package/dist/vue.js +1 -1
  175. package/package.json +25 -8
  176. package/scripts/bench-ratios-reporter.mjs +130 -91
  177. package/scripts/browser-channel.mjs +34 -0
  178. package/scripts/build.mjs +8 -0
  179. package/scripts/check-ascii.mjs +1 -1
  180. package/scripts/check-size.mjs +103 -3
  181. package/scripts/gate.mjs +7 -6
  182. package/scripts/generate-errors-doc.mjs +60 -0
  183. package/scripts/stamp-docs.mjs +13 -8
  184. package/scripts/test-counts-reporter.mjs +3 -2
  185. package/src/chamber-vapor.ts +15 -2
  186. package/src/chamber.ts +331 -227
  187. package/src/command-bus.ts +335 -269
  188. package/src/dict.ts +1 -1
  189. package/src/directives.ts +22 -3
  190. package/src/failure.ts +178 -0
  191. package/src/fast-lane.ts +56 -47
  192. package/src/form.ts +23 -13
  193. package/src/http-cache.ts +68 -24
  194. package/src/http-errors.ts +44 -8
  195. package/src/http.ts +269 -146
  196. package/src/iife-elements.ts +6 -0
  197. package/src/index.ts +11 -2
  198. package/src/ledger.ts +32 -6
  199. package/src/library-names.ts +22 -0
  200. package/src/mcp.ts +1 -1
  201. package/src/outbox.ts +74 -39
  202. package/src/plugins-core.ts +26 -22
  203. package/src/plugins-extra.ts +16 -24
  204. package/src/plugins-io.ts +8 -1
  205. package/src/plugins-schema.ts +6 -8
  206. package/src/poll-with.ts +94 -0
  207. package/src/router/blade.ts +11 -17
  208. package/src/router/composables.ts +1 -1
  209. package/src/router/dom.ts +17 -14
  210. package/src/router/engine.ts +147 -65
  211. package/src/router/errors.ts +67 -61
  212. package/src/router/index.ts +36 -25
  213. package/src/router/loaders.ts +15 -5
  214. package/src/router/menu.ts +3 -3
  215. package/src/router/outlet.ts +1 -1
  216. package/src/router/revalidate.ts +2 -2
  217. package/src/router/table.ts +7 -6
  218. package/src/router/types.ts +2 -1
  219. package/src/router/vapor.ts +47 -22
  220. package/src/router-fetch/index.ts +6 -2
  221. package/src/schema.ts +71 -20
  222. package/src/settled.ts +4 -1
  223. package/src/shared-bus.ts +70 -0
  224. package/src/ssr.ts +12 -10
  225. package/src/store/core.ts +45 -0
  226. package/src/store-base.ts +427 -0
  227. package/src/store.ts +46 -206
  228. package/src/testing.ts +35 -29
  229. package/src/transitions/vapor.ts +210 -0
  230. package/src/transitions.ts +8 -1
  231. package/src/transports.ts +124 -117
  232. package/src/vapor.ts +33 -2
  233. package/src/vite-hmr.ts +54 -12
  234. package/src/vitest.ts +3 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,1125 @@
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
+
5
1124
  ## v1.25.0 - 2026-09-29
6
1125
 
7
1126
  ### Changed (breaking): a failure's code is `owner:condition:subject`, and the owner comes from the wiring
@@ -221,8 +1340,8 @@ A custom `isRetryable` reads the same condition the default rule does
221
1340
  (`failureCondition(error)`: a library failure's own condition, an HTTP
222
1341
  response's status through the status table, a timeout, an abort, `lost`),
223
1342
  instead of matching statuses inside `error.message`, which is what
224
- `examples/feature-retry.ts` taught; it now uses it, and `retrying()` for the
225
- wire.
1343
+ `examples/feature-retry.ts` taught; it now reads the condition (`conditionOf`)
1344
+ and leaves the re-sending to the async bus.
226
1345
 
227
1346
  ### Changed (breaking, reference controller): an `Idempotency-Key` that is not a String is ignored
228
1347