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.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.