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/reactuseMusubiRootSuspense no 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 .then ran 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 spurious unmount + mount burst 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 (key as a real SharedRootMount field, cancelCleanupTimer / buildMountOptions helpers) 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_mounted reply on duplicate (module, id) now carries the existing root_id; the client aliases to its local RootConnection, bumps a local refCount, and shares one StoreProxy across all consumers. The last unmount defers 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 as MusubiInconsistencyError instead of being swallowed. Dev-mode warns when an alias has different params than the original mount. Wire protocol additions: :already_mounted :error reply 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 .catch so rejecting before any awaiter is observing is safe, and the mount push is settled synchronously via a cancelMountPush hook so the caller doesn't wait for Phoenix's push timeout. Version-mismatch recovery that hits a stale :already_mounted reply (server still has our entry after our recovery unmount push failed to land) no longer hangs forever waiting for an initial patch the server won't re-emit — it logs and force-cascades a full disconnectConnectionState (channel left + runtime entry removed) so consumers see a clean tear-down. Grace-timer cancellation (alias-remount, disconnect) now settles the awaiting unmount() caller through a pendingUnmountResolver rather than hanging it forever. The grace timer skips teardown when a concurrent mount for the same (module, callerId) is in flight, and mountConnectionRoot's finally re-arms teardown for any root left orphaned because that pending mount then settled :error instead of aliasing. channel.leave() is now called with connectionState.channel pre-cleared and inside a try/finally that guarantees roots and the runtime entry are dropped even if leave() throws synchronously. handleConnectionDisconnect now clears connectionState.roots (was only disconnectConnectionState) so a subsequent mount on the reconnecting state can't alias to a disconnected entry. Server-side unmount-push failures are now logged via console.warn instead of bubbling to the consumer's await 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 wire root_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 literal root_id values must update. See spec/domains/runtime/features/connection-root-identity.feature (#65).

0.6.1 — 2026-05-30

Fixed

  • TransportMusubi.Transport.Socket.build_connect_socket/2 no longer crashes the WebSocket handshake with FunctionClauseError when Phoenix's cookie session store delivers connect_info = %{session: nil} (the shape it produces on a cookieless first visit). The handler now normalizes nil to %{} before passing the session through to Musubi.Socket.put_session/2 (#63).
  • @musubi/react — Drop the react ^18.3.0 / react-dom ^18.3.0 devDependencies that were causing pnpm-workspace consumers on React 19 to ship two React copies in their production bundle and crash with minified React error #525 on the first Suspense render. React is now hoisted at the repo root and pinned via pnpm.overrides; the package's public peerDependencies (react ^18.2.0 || ^19.0.0) is unchanged (#63).
  • @musubi/reactuseMusubiRootSuspense no 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 a FinalizationRegistry-backed safety net: each render-phase mount allocates a fresh unregister token and adds the fiber's useId claim to a Set<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 while refs > 0 (a committed consumer owns the lifecycle). Falls back to "cleanup on channel termination" on hosts that lack FinalizationRegistry. (#63).

0.6.0 — 2026-05-28

Added

  • Musubi.Testing.dispatch_command/4 now accepts a native (atom-keyed, atom-valued) payload and wire-encodes it via Musubi.Wire.to_wire/1 before dispatch, so handle_command/3 receives 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 egress to_wire encoding 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/1 moves to the transport egress (#59). Revises #57. Client wire contract unchanged. Breaking (Elixir API): tests asserting wire-shaped replies from dispatch_command/3 / command/4 must 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_command hooks and [:musubi, :auth, :deny] telemetry still see the raw reply (atom keys/values).

Added

0.3.0 — 2026-05-20

Added

  • File uploads (#54). Top-level upload :name, opts DSL declared per store, outside state do. The framework auto-injects {"__musubi_upload__": "<name>"} markers into render output. Upload state ships through an independent upload_ops envelope stream (config / add / progress / complete / error / cancel / reset), parallel to stream_ops; progress mutation does not pollute __changed__ or trigger render/1. Authorization uses a per-entry musubi_upload:<entry_ref> sub-channel joined with a Phoenix.Token (HMAC, max_age: 600). External (S3/R2 direct) mode ships in v1 via the optional upload_external/3 callback. Store facade: consume_uploaded_entries/3, cancel_upload/3, uploaded_entries/2. New optional callback: handle_progress/3. Client surface exposes page.<name> as a stable reactive UploadHandle with TanStack-style status enum and isXxx mirrors; no separate React hook. Full reference in docs/uploads.md; design decisions in spec/decisions/BDR-0024..0028.

Changed

  • BREAKING (DSL)command :name, ... is replaced by the block-form command :name do ... end, with explicit payload do ... end and reply do ... end sub-blocks for schema declaration. Reply validation is now mandatory when a reply do block is declared. Migration: rewrite each command :name, payload: ..., reply: ... call as the block form (#53).
  • README documents how to wire a Phoenix endpoint socket for Musubi (#52).

Fixed

  • cart_page example: declare command reply types so the example compiles under the strict reply validation (#51).

0.2.0 — 2026-05-18

Added

  • Musubi.Testing test harness — mount/3, dispatch_command/4, render/2, and the assigns/2 escape hatch for asserting on store state from ExUnit.
  • createMusubi client 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 raw Phoenix.Socket directly.
  • Structured command errors. useMusubiCommand now 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 Arbor to Musubi throughout the codebase, docs, and configuration.
  • Arbor.Store facade reshaped to mirror LiveView's call surface, including assign_new/3 and update/3.

Performance

  • Resolver short-circuits render/1 when the root socket is unchanged; cached child wire_state stitches 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 Jsonpatch diffing when the wire root is structurally equal between cycles.
  • Client invalidates snapshotCache by 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.Store with state do … end, command handlers, async helpers, and a render/1 callback.
  • 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_ops delta 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.