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.12.0 — 2026-06-27
Fixed
musubi/@musubi/client— A WebSocket reconnect no longer leaves a root store permanently unrecoverable (Command "…" failed: Store is not connecteduntil a full page reload). The whole connection multiplexed every root over onemusubi:connectiontopic/channel; on a drop, Phoenix's automatic rejoin of the (never-leave()d) channel collided on that single topic with the new channel the recovery path opened, Phoenix closed the duplicate join, the state machine derailed,mountwas never re-sent, and the server'sterminate/2stop_rootwas never reversed. Each root store now owns its own channel, so recovery rides Phoenix's per-channel rejoin with no collision (see Changed).
Changed
musubi/@musubi/client— Breaking wire protocol. Each root store now gets its own channel on topicmusubi:connection:<root_id>instead of one sharedmusubi:connectionchannel multiplexing all roots byroot_id. Join carries{module, id, params}and is the mount (the join reply returns the canonicalroot_id); leaving the channel (clientleave()or a transport drop) is the unmount and stops that one root viaterminate/2. Reconnect recovery now reuses Phoenix's built-in per-channel rejoin: thejoin().receive("ok")hook re-fires on every rejoin and rebuilds the root, keeping the last-good snapshot until the server's fresh initial patch swaps it in. Thecommand/allow_upload/cancel_upload/upload_progress/upload_errorpayloads no longer carry aroot_id(one root per channel). Client and server must be upgraded together.
Removed
@musubi/client—SocketLike.onOpenis gone (added in 0.11.0 to drive the socket-level reopen handler). Recovery is now per-channel, so the socket-levelonOpen/handleSocketReopen/reestablishConnectionRootsmachinery — and themount/unmount/already_mountedchannel messages — were removed. CustomSocketLikeimplementations need onlyconnectandchannel.
0.11.1 — 2026-06-25
Fixed
@musubi/client— A silent WebSocket drop (cleansocket.disconnect()swallowed by an iOS Safari bfcache freeze) no longer leaves the client with no live data after resume.handleSocketReopenbailed wheneverconnectionState.channelwas set, butsocket.onOpenfires only on a fresh transport — a channel still held at that point is a zombie bound to the dead prior transport. When the WSoncloseis never delivered,handleConnectionDisconnectnever runs, soconnectionState.channeland every liveroot.channelstay stale and the bail skipped the remount entirely. The reopen guard now keys onconnectPromise(the real "connect in flight" signal) and, when a stale channel is present, runshandleConnectionDisconnectto normalize state (version→ 0, channels cleared) before re-establishing, so the live roots are actually re-mounted. Completes the reconnect story from 0.11.0, which only covered the drop path where the channelonClose/onErrorfires.
0.11.0 — 2026-06-24
Fixed
@musubi/client— A hard WebSocket drop (iOS Safari backgrounded then resumed, network loss, server restart) no longer blanks downstream consumers.handleConnectionDisconnectnow keeps the last-good root/index/streams/ snapshots for live roots (only resettingversionto 0) instead of wiping them and clearing the roots map, so mountedproxy.snapshot()keeps returning complete stale data through the reconnect window rather than collapsing to a missing-snapshot stub. On socket reopen, a newonOpenhook re-joins the connection channel and re-mounts each live root; the server's initial patch (whole-rootreplace "") atomically swaps fresh state in, so consumers refresh automatically with no navigation or manual reload. Completes the reconnect story from 0.10.0, which only covered the version-mismatch recovery path (The terminal disconnect path still resets).
Changed
@musubi/client—SocketLikegains a requiredonOpen(callback)method, used to drive reconnect recovery.Phoenix.Socketalready implements it; customSocketLikeimplementations must add it.
0.10.0 — 2026-06-24
Fixed
@musubi/client/@musubi/react— Snapshotting a store whose node is absent from the index now returnsundefinedinstead of a stub object cast to the fully-hydrated snapshot type. The stub typechecked as complete, so a consumer dereferencing a "guaranteed" field (snap.artifact.id) crashed at runtime — most visibly on a websocket reconnect (e.g. iOS Safari backgrounded then resumed) when a re-render hit the reset index.StoreProxy.snapshot()anduseMusubiSnapshotnow returnStoreSnapshot<M, R> | undefined. Breaking (types): unguarded.fieldaccess on a snapshot is now atscerror — guard withif (!snap) …(the same skeleton you render on cold mount).@musubi/client— Version-mismatch recovery no longer empties the store index before re-mounting. It keeps serving the last-good (stale-but-complete) snapshot through the remount window and lets the remount's initial patch (whole-rootreplace "") swap in fresh state atomically, closing the reconnect-window stub on the self-healing path. The terminal disconnect path still resets; the type guard above covers it.
0.9.2 — 2026-06-23
Fixed
musubi—Reconciler.reconcile_child/4no longer re-runs a child'supdate/2on every render when the parent passes byte-for-byte identical props. The parent-prop change gate now compares incoming props against a snapshot of the previously-passed props (Entry.consumed_assigns) instead of the child's livesocket.assigns, which the child mutates itself (reload, command, async write). Previously a child that overwrote a consumed-key-named assign looked like a parent change every cycle, re-firingupdate/2— an infinite render loop when a store calledMusubi.send_updatefrom its ownupdate/2. Behavior change (BDR-0030): asend_updatewrite to a key the parent also controls now persists until the parent prop actually changes, instead of reverting each cycle — the realPhoenix.LiveViewchange-tracking rule (#81).
0.9.1 — 2026-06-21
Fixed
musubi—assign_async/stream_asyncre-assigning a name already in flight no longer kills the prior task (including with:reset). The runtime drops its tracking instead; the prior task runs to completion and its result /:DOWNlazy-discards by ref. This matchesPhoenix.LiveView(which never exits a producer on re-assign) and extends thestart_asyncrule (BDR-0019) to all three async APIs. Onlycancel_async/2,3and:timeoutactively kill. Previously the unconditional kill could terminate a task mid-DB-call and tear down a shared Ecto sandbox connection (:CONNECTION_DEAD) — seedocs/review-store-async-sqlite-problem.md(BDR-0031).
0.9.0 — 2026-06-17
Added
musubi—Musubi.send_update/2,3, aligned withPhoenix.LiveView.send_update, lets the server target one mounted child store bystore_idwith new assigns. The map is delivered to the store'supdate/2; only that subtree re-renders and one scoped JSON Patch envelope ships (the clean root short-circuits its ownrender/1). It is the intra-page last hop for cross-connection fan-out coordinated overPhoenix.PubSub— Musubi owns the targeting, the application owns the broadcast (no built-in PubSub abstraction). The two-arity form sends toself()(call it from the root'shandle_info/2); the three-arity form targets an explicit page pid. Addressing the root ([]) is allowed. Astore_idthat no longer resolves to a mounted store is a no-op and emits[:musubi, :send_update, :no_target]telemetry (BDR-0030, #76).
0.8.0 — 2026-06-13
Added
@musubi/client/@musubi/react— Opt-in, TanStack-Query-style stale-while-revalidate store cache. Mounting a store whose identity was seen before seeds last-known state immediately (fromCache: true) while the live mount revalidates in the background and swaps in fresh state when the server's initial patch lands. Enabled per call viaMountStoreOptions.cache({ gcTime?, persister?, buster?, initialData? }). Storage is pluggable throughMusubiCachePersister— the default is a connection-scoped in-memory Map (cleared ondisconnect);createStorageCachePersisteradaptslocalStorage/sessionStorage. Accepted patches write through to the cache on a per-key trailing throttle and flush on teardown / disconnect.gcTime(default 5 min) is measured from the entry's last update and enforced at read so it survives reloads. Abusterstring discards stale data shapes across deploys, with a dev warning when a durable persister is used without one. Commands dispatched during the stale window are queued behind the live initial patch instead of rejecting. New surface:MountedStore.{fromCache, isFetching, revalidated},MusubiConnection.clearStoreCache(target?), and thecreateMemoryPersister/createStorageCachePersister/storeCacheKeyexports. In@musubi/react,useMusubiRoot's result gainsisFetchingandrevalidationError, plus akeepPreviousDataoption that keeps the prior store visible across anid/paramschange until the new mount resolves. Non-cached mounts are unchanged (#74).
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.