Skip to main content

CHANGELOG.md

# Changelog

All notable changes to this project are documented here.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

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`** — `useMusubiRootSuspense` 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

- **Transport** — `Musubi.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/react`** — `useMusubiRootSuspense` 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

- `Musubi.Wire` support for `DateTime`/`NaiveDateTime`/`Date`/`Time`
  (ISO8601) and `URI` (string) (#57). `MapSet`, `Decimal`, and tuples stay
  unhandled and raise `Protocol.UndefinedError` — convert first.

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

[Unreleased]: https://github.com/fahchen/musubi/compare/v0.7.2...HEAD
[0.7.2]: https://github.com/fahchen/musubi/compare/v0.7.1...v0.7.2
[0.7.1]: https://github.com/fahchen/musubi/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/fahchen/musubi/compare/v0.6.1...v0.7.0
[0.6.1]: https://github.com/fahchen/musubi/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/fahchen/musubi/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/fahchen/musubi/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/fahchen/musubi/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/fahchen/musubi/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/fahchen/musubi/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/fahchen/musubi/releases/tag/v0.1.0