All notable changes to this project are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
The Elixir package (musubi) and the JS packages (@musubi/client,
@musubi/react) share this changelog. Per-package version numbers are
not in lockstep yet; entries note which surface they affect.
Unreleased
0.7.2 — 2026-06-05
Fixed
@musubi/react—useMusubiRootSuspenseno longer double-mounts a root over the wire on react-router v7 SPA navigation. When the last render-phase claimer dropped while the mount was still in flight, the orphan sweep chained teardown inline on the mount promise; that.thenran in the same microtask flush as the mount settle — ahead of React's MessageChannel-scheduled resumed Suspense render — so the resumed render found no shared entry and allocated a fresh mount, producing a spuriousunmount+mountburst right after the first patch. Teardown is now deferred two macrotask hops past the settle (React's render-phase then commit-phase) so the resumed render re-claims the entry first and the existing ref / claimer guards bail. Also tidied the shared-mount bookkeeping (keyas a realSharedRootMountfield,cancelCleanupTimer/buildMountOptionshelpers) with no behavior change (#70).
0.7.1 — 2026-05-31
Fixed
- Transport /
@musubi/client— Restored multi-observer ergonomics regressed in 0.7.0. The server's:already_mountedreply on duplicate(module, id)now carries the existingroot_id; the client aliases to its localRootConnection, bumps a local refCount, and shares oneStoreProxyacross all consumers. The lastunmountdefers the server push via a brief grace timer so a route-swap remount within the same React commit batch cancels the teardown. Out-of-sync state (server reports mounted, client has no record) surfaces asMusubiInconsistencyErrorinstead of being swallowed. Dev-mode warns when an alias has differentparamsthan the original mount. Wire protocol additions::already_mounted:errorreply payload now carries"root_id". The 0.7.0 cross-module isolation ("<module>:<caller-id>"composite root_id) is unchanged (#67). @musubi/client— Hardened the mount / unmount / disconnect interplay against several real edge cases (#68). Mid-mount disconnect no longer surfaces as an unhandled rejection: the in-flight tentative's initial-patch waiter is now shielded by a pre-attached.catchso rejecting before any awaiter is observing is safe, and the mount push is settled synchronously via acancelMountPushhook so the caller doesn't wait for Phoenix's push timeout. Version-mismatch recovery that hits a stale:already_mountedreply (server still has our entry after our recoveryunmountpush failed to land) no longer hangs forever waiting for an initial patch the server won't re-emit — it logs and force-cascades a fulldisconnectConnectionState(channel left + runtime entry removed) so consumers see a clean tear-down. Grace-timer cancellation (alias-remount, disconnect) now settles the awaitingunmount()caller through apendingUnmountResolverrather than hanging it forever. The grace timer skips teardown when a concurrent mount for the same(module, callerId)is in flight, andmountConnectionRoot'sfinallyre-arms teardown for any root left orphaned because that pending mount then settled:errorinstead of aliasing.channel.leave()is now called withconnectionState.channelpre-cleared and inside atry/finallythat guaranteesrootsand the runtime entry are dropped even ifleave()throws synchronously.handleConnectionDisconnectnow clearsconnectionState.roots(was onlydisconnectConnectionState) so a subsequent mount on the reconnecting state can't alias to a disconnected entry. Server-side unmount-push failures are now logged viaconsole.warninstead of bubbling to the consumer'sawait mounted.unmount()— local state is already torn down by then; the release promise resolves cleanly.
0.7.0 — 2026-05-31
Changed (breaking — wire protocol)
- Transport /
@musubi/client— Connection roots are now identified by(module, caller id); the server composes and assigns the wireroot_id, which the client treats as opaque. Fixes silent state corruption when two roots shared a caller id. Duplicate(module, id)on one connection is rejected with:already_mounted. Client-side dedup removed. Tooling that pinned literalroot_idvalues must update. Seespec/domains/runtime/features/connection-root-identity.feature(#65).
0.6.1 — 2026-05-30
Fixed
- Transport —
Musubi.Transport.Socket.build_connect_socket/2no longer crashes the WebSocket handshake withFunctionClauseErrorwhen Phoenix's cookie session store deliversconnect_info = %{session: nil}(the shape it produces on a cookieless first visit). The handler now normalizesnilto%{}before passing the session through toMusubi.Socket.put_session/2(#63). @musubi/react— Drop thereact ^18.3.0/react-dom ^18.3.0devDependencies that were causing pnpm-workspace consumers on React 19 to ship two React copies in their production bundle and crash with minified React error#525on the first Suspense render. React is now hoisted at the repo root and pinned viapnpm.overrides; the package's publicpeerDependencies(react ^18.2.0 || ^19.0.0) is unchanged (#63).@musubi/react—useMusubiRootSuspenseno longer wedges Suspense in an infinite mount/unmount loop under React 19. The previous timer-based orphan sweep raced React 19's MessageChannel-scheduled commit and tore the mount entry down before any consumer could claim it. The cleanup path is now aFinalizationRegistry-backed safety net: each render-phase mount allocates a fresh unregister token and adds the fiber'suseIdclaim to aSet<claimerId>on the shared entry. The finalizer fires only after React releases the discarded fiber, drops this fiber's claim, and bails while the set is non-empty (other sibling consumers still hold the entry) or whilerefs > 0(a committed consumer owns the lifecycle). Falls back to "cleanup on channel termination" on hosts that lackFinalizationRegistry. (#63).
0.6.0 — 2026-05-28
Added
Musubi.Testing.dispatch_command/4now accepts a native (atom-keyed, atom-valued) payload and wire-encodes it viaMusubi.Wire.to_wire/1before dispatch, sohandle_command/3receives the same string-keyed map a real client delivers (#61). Tests can write%{by: 3}instead of%{"by" => 3}; the encode is idempotent on existing string-keyed payloads, so this is non-breaking. Symmetric with the egressto_wireencoding of command replies (#59).
0.5.0 — 2026-05-27
Changed
- Command replies are now returned in native Elixir shape (atom keys,
structs, atom values), symmetric with
render/1;Musubi.Wire.to_wire/1moves to the transport egress (#59). Revises #57. Client wire contract unchanged. Breaking (Elixir API): tests asserting wire-shaped replies fromdispatch_command/3/command/4must switch to native shape.
0.4.0 — 2026-05-26
Changed
- Command replies now serialize through
Musubi.Wire(#57). Replies match the wire shape the client receives (string keys, stringified atoms), and schema validation runs against that form — fixing atom-valued and nested reply-field validation.:after_commandhooks and[:musubi, :auth, :deny]telemetry still see the raw reply (atom keys/values).
Added
Musubi.Wiresupport forDateTime/NaiveDateTime/Date/Time(ISO8601) andURI(string) (#57).MapSet,Decimal, and tuples stay unhandled and raiseProtocol.UndefinedError— convert first.
0.3.0 — 2026-05-20
Added
- File uploads (#54). Top-level
upload :name, optsDSL declared per store, outsidestate do. The framework auto-injects{"__musubi_upload__": "<name>"}markers into render output. Upload state ships through an independentupload_opsenvelope stream (config / add / progress / complete / error / cancel / reset), parallel tostream_ops; progress mutation does not pollute__changed__or triggerrender/1. Authorization uses a per-entrymusubi_upload:<entry_ref>sub-channel joined with aPhoenix.Token(HMAC,max_age: 600). External (S3/R2 direct) mode ships in v1 via the optionalupload_external/3callback. Store facade:consume_uploaded_entries/3,cancel_upload/3,uploaded_entries/2. New optional callback:handle_progress/3. Client surface exposespage.<name>as a stable reactiveUploadHandlewith TanStack-stylestatusenum andisXxxmirrors; no separate React hook. Full reference indocs/uploads.md; design decisions inspec/decisions/BDR-0024..0028.
Changed
- BREAKING (DSL) —
command :name, ...is replaced by the block-formcommand :name do ... end, with explicitpayload do ... endandreply do ... endsub-blocks for schema declaration. Reply validation is now mandatory when areply doblock is declared. Migration: rewrite eachcommand :name, payload: ..., reply: ...call as the block form (#53). - README documents how to wire a Phoenix endpoint socket for Musubi (#52).
Fixed
cart_pageexample: declare command reply types so the example compiles under the strict reply validation (#51).
0.2.0 — 2026-05-18
Added
Musubi.Testingtest harness —mount/3,dispatch_command/4,render/2, and theassigns/2escape hatch for asserting on store state from ExUnit.createMusubiclient factory — bind a store type once and reuse the resulting page/command/subscribe API across an application.- React Suspense integration and an
<MusubiProvider>that accepts a rawPhoenix.Socketdirectly. - Structured command errors.
useMusubiCommandnow returns a mutation-shaped value (mutate,isPending,data,error, …). - Phoenix matrix in CI; publish workflow; MIT LICENSE and README badges.
Changed
- BREAKING (rename) — Package renamed from
ArbortoMusubithroughout the codebase, docs, and configuration. Arbor.Storefacade reshaped to mirror LiveView's call surface, includingassign_new/3andupdate/3.
Performance
- Resolver short-circuits
render/1when the root socket is unchanged; cached childwire_statestitches into the parent wire output without re-walk. - Reconciler checks parent assign value equality before computing changed-key intersections; deep-tree leaf dirty detection is prune-safe.
- Page server skips
Jsonpatchdiffing when the wire root is structurally equal between cycles. - Client invalidates
snapshotCacheby op path instead of clearing the entire cache.
Fixed
- Reconciler deep-tree leaf dirty detection now survives prune cycles without losing references.
0.1.0 — 2026-05-17
Initial public release of the Musubi runtime (then Arbor):
- Server-authoritative, page-scoped runtime over
Phoenix.Channel. - Stores declared via
use Musubi.Storewithstate do … end, command handlers, async helpers, and arender/1callback. - Per-page diff pipeline emitting RFC 6902 JSON Patch envelopes.
- LV-aligned change tracking via per-key
__changed__flags. - Streams with stable wire markers and an independent
stream_opsdelta channel; LiveView-aligned semantics. - Async helpers:
assign_async/3,4,start_async/3,4,cancel_async/2,3,stream_async/3,4. - TypeScript client and React adapter that materialize the diff stream into immutable snapshots.