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.
- package/CHANGELOG.md +1684 -0
- package/README.md +194 -102
- package/ROADMAP.md +15 -15
- package/dist/LICENSE.txt +12 -3
- package/dist/a11y.d.ts +29 -0
- package/dist/a11y.d.ts.map +1 -0
- package/dist/a11y.js +43 -0
- package/dist/a11y.js.map +1 -0
- package/dist/blade.js +6 -6
- package/dist/blade.js.map +1 -1
- package/dist/chamber-vapor.d.ts +18 -19
- package/dist/chamber-vapor.d.ts.map +1 -1
- package/dist/chamber-vapor.js +6 -5
- package/dist/chamber-vapor.js.map +1 -1
- package/dist/chamber.d.ts +27 -89
- package/dist/chamber.d.ts.map +1 -1
- package/dist/chamber.js +190 -167
- package/dist/chamber.js.map +1 -1
- package/dist/command-bus.d.ts +168 -151
- package/dist/command-bus.d.ts.map +1 -1
- package/dist/command-bus.js +423 -282
- package/dist/command-bus.js.map +1 -1
- package/dist/dict.d.ts +2 -2
- package/dist/dict.js.map +1 -1
- package/dist/directives.d.ts +50 -78
- package/dist/directives.d.ts.map +1 -1
- package/dist/directives.js +2 -2
- package/dist/directives2.js +72 -6
- package/dist/directives2.js.map +1 -1
- package/dist/failure.d.ts +89 -0
- package/dist/failure.d.ts.map +1 -0
- package/dist/failure.js +70 -0
- package/dist/failure.js.map +1 -0
- package/dist/fast-lane.d.ts +4 -3
- package/dist/fast-lane.d.ts.map +1 -1
- package/dist/fast-lane.js +29 -18
- package/dist/fast-lane.js.map +1 -1
- package/dist/form.d.ts +30 -1
- package/dist/form.d.ts.map +1 -1
- package/dist/freeze.js.map +1 -1
- package/dist/http-cache.d.ts +26 -10
- package/dist/http-cache.d.ts.map +1 -1
- package/dist/http-errors.d.ts +52 -20
- package/dist/http-errors.d.ts.map +1 -1
- package/dist/http.d.ts +60 -76
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +226 -104
- package/dist/http.js.map +1 -1
- package/dist/iife-core.d.ts +8 -10
- package/dist/iife-core.d.ts.map +1 -1
- package/dist/iife-core.js +13 -10
- package/dist/iife-core.js.map +1 -1
- package/dist/iife-elements.d.ts +15 -14
- package/dist/iife-elements.d.ts.map +1 -1
- package/dist/iife-elements.js +12 -9
- package/dist/iife-elements.js.map +1 -1
- package/dist/iife.d.ts +7 -9
- package/dist/iife.d.ts.map +1 -1
- package/dist/iife.js +14 -10
- package/dist/iife.js.map +1 -1
- package/dist/index.d.ts +14 -39
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +659 -189
- package/dist/index.js.map +1 -1
- package/dist/keys.js +11 -9
- package/dist/keys.js.map +1 -1
- package/dist/ledger.d.ts +33 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +73 -0
- package/dist/ledger.js.map +1 -0
- package/dist/library-names.d.ts +15 -0
- package/dist/library-names.d.ts.map +1 -0
- package/dist/library-names.js +13 -0
- package/dist/library-names.js.map +1 -0
- package/dist/mcp.d.ts +5 -25
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +5 -7
- package/dist/mcp.js.map +1 -1
- package/dist/outbox.d.ts +34 -17
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +34 -16
- package/dist/outbox.js.map +1 -1
- package/dist/plugins-core.d.ts +9 -18
- package/dist/plugins-core.d.ts.map +1 -1
- package/dist/plugins-core.js +204 -0
- package/dist/plugins-core.js.map +1 -0
- package/dist/plugins-extra.d.ts +3 -1
- package/dist/plugins-extra.d.ts.map +1 -1
- package/dist/plugins-io.d.ts +7 -83
- package/dist/plugins-io.d.ts.map +1 -1
- package/dist/plugins-io.js +6 -289
- package/dist/plugins-io.js.map +1 -1
- package/dist/plugins-schema.d.ts +1 -1
- package/dist/plugins-schema.d.ts.map +1 -1
- package/dist/plugins-schema.js +8 -11
- package/dist/plugins-schema.js.map +1 -1
- package/dist/plugins.d.ts +2 -2
- package/dist/plugins.d.ts.map +1 -1
- package/dist/poll-with.d.ts +38 -0
- package/dist/poll-with.d.ts.map +1 -0
- package/dist/reactive.js +1 -1
- package/dist/reactive.js.map +1 -1
- package/dist/router/announce.d.ts +14 -0
- package/dist/router/announce.d.ts.map +1 -0
- package/dist/router/blade.d.ts.map +1 -1
- package/dist/router/composables.d.ts +2 -3
- package/dist/router/composables.d.ts.map +1 -1
- package/dist/router/dom.d.ts +5 -0
- package/dist/router/dom.d.ts.map +1 -1
- package/dist/router/engine.d.ts +13 -10
- package/dist/router/engine.d.ts.map +1 -1
- package/dist/router/errors.d.ts +25 -16
- package/dist/router/errors.d.ts.map +1 -1
- package/dist/router/index.d.ts +17 -3
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +2 -2
- package/dist/router/loaders.d.ts +12 -3
- package/dist/router/loaders.d.ts.map +1 -1
- package/dist/router/menu.d.ts +7 -2
- package/dist/router/menu.d.ts.map +1 -1
- package/dist/router/remote.d.ts +8 -13
- package/dist/router/remote.d.ts.map +1 -1
- package/dist/router/remote.js +1 -1
- package/dist/router/remote.js.map +1 -1
- package/dist/router/revalidate.d.ts +9 -16
- package/dist/router/revalidate.d.ts.map +1 -1
- package/dist/router/table.d.ts +6 -4
- package/dist/router/table.d.ts.map +1 -1
- package/dist/router/types.d.ts +7 -3
- package/dist/router/types.d.ts.map +1 -1
- package/dist/router/url.d.ts.map +1 -1
- package/dist/router/vapor.d.ts +7 -9
- package/dist/router/vapor.d.ts.map +1 -1
- package/dist/router/vapor.js +11 -8
- package/dist/router/vapor.js.map +1 -1
- package/dist/router/vdom.js +1 -1
- package/dist/router/vdom.js.map +1 -1
- package/dist/router-fetch/index.d.ts.map +1 -1
- package/dist/router-fetch/index.js +3 -2
- package/dist/router-fetch/index.js.map +1 -1
- package/dist/router.js +239 -111
- package/dist/router.js.map +1 -1
- package/dist/scheduler.d.ts +31 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/schema.d.ts +31 -12
- package/dist/schema.d.ts.map +1 -1
- package/dist/settled.d.ts +28 -55
- package/dist/settled.d.ts.map +1 -1
- package/dist/settled.js +24 -1
- package/dist/settled.js.map +1 -1
- package/dist/shared-bus.d.ts +46 -0
- package/dist/shared-bus.d.ts.map +1 -0
- package/dist/shared-bus.js +24 -0
- package/dist/shared-bus.js.map +1 -0
- package/dist/signal.d.ts.map +1 -1
- package/dist/signal.js +3 -5
- package/dist/signal.js.map +1 -1
- package/dist/ssr.d.ts +9 -41
- package/dist/ssr.d.ts.map +1 -1
- package/dist/ssr2.js +6 -7
- package/dist/ssr2.js.map +1 -1
- package/dist/store/core.d.ts +4 -0
- package/dist/store/core.d.ts.map +1 -0
- package/dist/store/core.js +34 -0
- package/dist/store/core.js.map +1 -0
- package/dist/store-base.d.ts +104 -0
- package/dist/store-base.d.ts.map +1 -0
- package/dist/store-base.js +217 -0
- package/dist/store-base.js.map +1 -0
- package/dist/store-core.d.ts +4 -0
- package/dist/store-core.d.ts.map +1 -0
- package/dist/store.d.ts +22 -55
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +39 -77
- package/dist/store.js.map +1 -1
- package/dist/stream-parser.d.ts +2 -4
- package/dist/stream-parser.d.ts.map +1 -1
- package/dist/stream-parser.js +2 -4
- package/dist/stream-parser.js.map +1 -1
- package/dist/testing.d.ts +14 -5
- package/dist/testing.d.ts.map +1 -1
- package/dist/transitions/vapor.d.ts +102 -0
- package/dist/transitions/vapor.d.ts.map +1 -0
- package/dist/transitions/vapor.js +107 -0
- package/dist/transitions/vapor.js.map +1 -0
- package/dist/transitions.d.ts +21 -36
- package/dist/transitions.d.ts.map +1 -1
- package/dist/transitions2.js +2 -1
- package/dist/transitions2.js.map +1 -1
- package/dist/transports.d.ts +42 -75
- package/dist/transports.d.ts.map +1 -1
- package/dist/transports.js +1 -420
- package/dist/transports2.js +418 -0
- package/dist/transports2.js.map +1 -0
- package/dist/utilities.d.ts +1 -1
- package/dist/utilities.d.ts.map +1 -1
- package/dist/vapor-chamber-core.iife.js +1070 -958
- package/dist/vapor-chamber-core.iife.js.map +1 -1
- package/dist/vapor-chamber-core.iife.min.js +1 -1
- package/dist/vapor-chamber-elements.iife.js +1133 -1018
- package/dist/vapor-chamber-elements.iife.js.map +1 -1
- package/dist/vapor-chamber-elements.iife.min.js +1 -1
- package/dist/vapor-chamber.iife.js +1609 -1282
- package/dist/vapor-chamber.iife.js.map +1 -1
- package/dist/vapor-chamber.iife.min.js +1 -1
- package/dist/vapor.d.ts +18 -4
- package/dist/vapor.d.ts.map +1 -1
- package/dist/vapor.js +6 -3
- package/dist/vapor.js.map +1 -1
- package/dist/vite-hmr.d.ts +63 -93
- package/dist/vite-hmr.d.ts.map +1 -1
- package/dist/vite-hmr.js +17 -11
- package/dist/vite-hmr.js.map +1 -1
- package/dist/vitest-mcp.js +3 -3
- package/dist/vitest-mcp.js.map +1 -1
- package/dist/vitest-pure.d.ts +10 -20
- package/dist/vitest-pure.d.ts.map +1 -1
- package/dist/vitest-pure.js +11 -21
- package/dist/vitest-pure.js.map +1 -1
- package/dist/vitest.d.ts.map +1 -1
- package/dist/vitest.js +1 -0
- package/dist/vitest.js.map +1 -1
- package/dist/vue.d.ts +9 -18
- package/dist/vue.d.ts.map +1 -1
- package/dist/vue.js +1 -1
- package/dist/vue.js.map +1 -1
- package/package.json +28 -8
- package/scripts/bench-ratios-reporter.mjs +130 -91
- package/scripts/browser-channel.mjs +34 -0
- package/scripts/build.mjs +19 -13
- package/scripts/check-ascii.mjs +9 -18
- package/scripts/check-example-directive.mjs +7 -6
- package/scripts/check-size.mjs +204 -3
- package/scripts/gate.mjs +7 -9
- package/scripts/generate-errors-doc.mjs +60 -0
- package/scripts/measure-size.mjs +14 -22
- package/scripts/serve-docs.mjs +4 -5
- package/scripts/stamp-docs.mjs +19 -19
- package/scripts/test-counts-reporter.mjs +3 -2
- package/src/a11y.ts +67 -0
- package/src/chamber-vapor.ts +27 -24
- package/src/chamber.ts +428 -408
- package/src/command-bus.ts +706 -563
- package/src/dict.ts +2 -2
- package/src/directives.ts +206 -157
- package/src/failure.ts +178 -0
- package/src/fast-lane.ts +56 -47
- package/src/form.ts +131 -13
- package/src/http-cache.ts +73 -32
- package/src/http-errors.ts +69 -27
- package/src/http.ts +282 -206
- package/src/iife-core.ts +13 -18
- package/src/iife-elements.ts +20 -22
- package/src/iife.ts +11 -16
- package/src/index.ts +50 -52
- package/src/ledger.ts +141 -0
- package/src/library-names.ts +22 -0
- package/src/mcp.ts +12 -45
- package/src/outbox.ts +92 -45
- package/src/plugins-core.ts +88 -171
- package/src/plugins-extra.ts +46 -76
- package/src/plugins-io.ts +26 -231
- package/src/plugins-schema.ts +10 -12
- package/src/plugins.ts +1 -3
- package/src/poll-with.ts +94 -0
- package/src/router/announce.ts +88 -0
- package/src/router/blade.ts +11 -17
- package/src/router/composables.ts +26 -24
- package/src/router/dom.ts +40 -30
- package/src/router/engine.ts +178 -107
- package/src/router/errors.ts +67 -62
- package/src/router/index.ts +74 -52
- package/src/router/loaders.ts +21 -13
- package/src/router/menu.ts +10 -5
- package/src/router/outlet.ts +3 -3
- package/src/router/remote.ts +8 -13
- package/src/router/revalidate.ts +39 -41
- package/src/router/table.ts +48 -31
- package/src/router/types.ts +7 -3
- package/src/router/url.ts +9 -12
- package/src/router/vapor.ts +54 -32
- package/src/router-fetch/index.ts +6 -2
- package/src/scheduler.ts +70 -0
- package/src/schema.ts +142 -70
- package/src/settled.ts +64 -59
- package/src/shared-bus.ts +70 -0
- package/src/signal.ts +7 -10
- package/src/ssr.ts +25 -67
- package/src/store/core.ts +45 -0
- package/src/store-base.ts +427 -0
- package/src/store.ts +46 -195
- package/src/stream-parser.ts +6 -10
- package/src/testing.ts +77 -58
- package/src/transitions/vapor.ts +210 -0
- package/src/transitions.ts +34 -64
- package/src/transports.ts +207 -320
- package/src/utilities.ts +10 -15
- package/src/vapor.ts +36 -5
- package/src/vite-hmr.ts +101 -133
- package/src/vitest-mcp.ts +4 -4
- package/src/vitest-pure.ts +27 -37
- package/src/vitest.ts +9 -2
- package/src/vue.ts +10 -19
- package/dist/http-errors.js +0 -11
- package/dist/http-errors.js.map +0 -1
- 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
|