All notable changes to this project are documented here. The format follows Keep a Changelog, and the project adheres to Semantic Versioning.

Unreleased

0.3.0-rc.0 — 2026-05-24

This release is a substantial overhaul of the DSL and template surface from 0.2.0. It collapses several legacy paths into the modern calculate / rx() / ~L model, introduces a non-DSL on-ramp (Lavash.LiveView.Explicit), and adds compile-time machinery (auto-injection, diagnostics, optimistic JS hook integration) that lets the user write near-vanilla Phoenix HEEx with reactive behavior wired in for free.

Breaking changes

  • Removed the derive DSL block. derive :name do argument ... run/compute end no longer compiles. Use calculate :name, rx(...) instead — the modern form already does what derive did, plus transpiles to JS for the optimistic path. Migration is mechanical: convert each argument declaration to a state/derive reference inside the rx(...) body.
  • Removed the update :field, fn action operation. Use set :field, rx(...) instead — same Elixir semantics plus client-side transpilation. Mechanical migration:
    • update :count, &(&1 + 1)set :count, rx(@count + 1)
    • update :flag, &(!&1)set :flag, rx(not @flag)
    • update :n, fn n -> (n || 0) + 1 endset :n, rx((@n || 0) + 1)
  • Removed the notify_parent :event action operation. No usage anywhere in this repo's demo or fixtures, and bind= + PubSub cover the state-sync and cross-process cases. For the genuine "child fires a callback on its parent" case, use send(self(), {:my_event, payload}) inside a run fn and pattern-match in handle_info/2.
  • Removed Lavash.Argument (the derive-specific argument struct). Read-side arguments use Lavash.Read.Argument and are unaffected.
  • Removed Lavash.Actions.Update and Lavash.Actions.NotifyParent structs, along with their runtime helpers.
  • Removed the :lavash_component_* message tag family (_set, _close, _delta, _add, _remove, _toggle) in favor of a single {:lavash_field_op, op, field, value} envelope. The _delta/_close/etc. handlers were dead code anyway; the _set handler is now reached via the new envelope.
  • ~L is required for Lavash LiveView and Component render functions — ~H works but skips the lavash template transformer (no auto-injection, no diagnostics). Existing code should already be on ~L; this is a documentation clarification rather than a runtime change.
  • Test fixtures renamespaced. Lavash.Test* modules in test/support/ moved to Lavash.Test.Magic.* (DSL fixtures) and a new Lavash.Test.Explicit.* namespace (plain Phoenix.LiveView fixtures). Demo applications consuming these fixtures (none expected) need to update.

Added

  • Lavash.LiveView.Explicit — non-DSL entry point. use Lavash.LiveView.Explicit + a reactive do state ... derive ... end block gets you the reactive graph with put_state/3, automatic mount, and async-derive dispatch, but no template transformer, no optimistic JS hook, no forms/overlays/bindings. For "I want the reactive engine but not the rest."
  • Compile-time diagnostics in the ~L template transformer:
    • Warn when a bare {@field} references a declared-but-non-optimistic state field (likely missing optimistic: true).
    • Warn when bind={[child: :parent]} targets a :parent that isn't a declared state field on the host.
  • Auto-injection of more data-lavash-* attributes from natural Phoenix HEEx patterns:
    • class={if @bool, do: A, else: B} on an optimistic boolean → auto-injects data-lavash-toggle.
    • class={if val in @list, do: A, else: B} on an optimistic array → auto-injects data-lavash-member + data-lavash-member-value.
    • <.lavash_component bind={[n: :p]}> → auto-injects n={@p} so the child receives the parent's value.
  • Compile-time graph cache invalidation. Lavash.Dsl.Graph, Lavash.Reactive, and Lavash.Rx.Cache register @after_compile hooks that drop their :persistent_term cache entries when a Lavash module recompiles in dev. Renamed fields and removed calculations no longer carry stale compute fns across reloads.
  • AST eval cache (Lavash.Rx.Cache) — user-supplied ASTs in rx() expressions and run functions are compiled once and stored in :persistent_term, replacing the per-fire Code.eval_quoted cost.
  • Browser-driven integration tests via Wallabidi + Lightpanda. 61 end-to-end tests cover counters, bindings, DOM directives, forms, arrays, overlays, optimistic state, and reconnects against real Lightpanda. The lavash JS hook is loaded into the test layout.
  • Lavash.LiveView.Compiler.collect_optimistic_fields/1 — public accessor for "state fields that should be optimistic" (both explicitly declared and implicitly via action-touched).
  • Lavash.State.MissingRequiredFieldError structured exception replacing the old bare-string raise in URL field hydration.
  • Lavash.Form.Runtime.extract_submit_errors/1 — single home for the Ash-error-to-field-error-map conversion that was duplicated in five places.
  • Lavash.Type.decode_wire/1 — JS-wire decoder unifying the previously-duplicated parse_value/1 and parse_binding_value/1.
  • Lavash.Component.CompilerHelpers.resource_available?/1 — unified the previously-duplicated Ash resource compile-readiness check.
  • __lavash__(:declared_actions) and __lavash__(:derives) — introspection accessors for callers that need the un-augmented user-declared entities (vs. the :actions accessor which also includes synthetic setters and optimistic actions).

Changed

  • Bindings are now bidirectional. The template transformer auto-injects the parent's bound-field value into the child's assigns, so the child sees parent updates as well as propagating its own writes back. Sibling components bound to the same parent field stay in sync via the parent.
  • User callbacks are overridable. handle_params/handle_event/ handle_info on LiveViews and update/handle_event on Components now have defoverridable declared, so users can write their own clauses and call super/N to fall through to the lavash dispatch.
  • :lavash_* catch-all logs. The handle_info catch-all in Lavash.LiveView.Runtime logs a warning when an unrecognized :lavash_* tuple arrives instead of silently dropping it. Library bugs surface in the log instead of vanishing.
  • component_states routed through assigns, not the process dictionary. The per-component-id state map (used to restore socket state across reconnects) now travels via @__lavash_component_states__ and propagates through nested <.lavash_component> calls. Visible in change tracking and in tests.
  • Three DSL metadata access patterns reduced to a clear partition. Transformer.get_entities for compile time, module.__lavash__/1 for the canonical runtime accessor, and Spark.Dsl.Extension.get_entities/2 as an escape hatch for the few callers that need un-augmented entities.
  • Phoenix.HTML.FormData protocol on Lavash.Form now implements to_form/4, input_value/3, and input_validations/3. Nested form inputs (<.inputs_for>) now work end-to-end.
  • Default Elixir/OTP versions pinned. .tool-versions declares elixir 1.19.0-otp-28 / erlang 28.1.
  • Pre-commit hook ships in .githooks/. Opt in with git config core.hooksPath .githooks. Runs format, compile --warnings-as-errors, credo --strict, and the full test suite.
  • Credo strict clean. The repo passes mix credo --strict with no findings; the pre-commit hook keeps it that way.

Fixed

  • apply_runs/4 was silently no-op for any non-initial action. Phoenix.Component.assign stores either true (initial render) or the old value (subsequent change) in __changed__; the old code pattern-matched only on the literal true and dropped every subsequent run-based action's writes. Now it accepts any marker.
  • inside_display_element? mis-tracked depth for void elements. <input>, <br>, etc. produce a :tag token with no matching :close, so they wrongly consumed the depth-0 slot when scanning back through the accumulator. Now skipped via closing: :void / closing: :self in tag meta.
  • Rx.qualify_local_calls broke imported helpers. Bare calls were rewritten to Caller.fn(...) regardless of whether the name was imported from another module. Now consults __CALLER__.functions / .macros and qualifies against the import's source module.
  • Silent try/rescue around apply_submits removed. Used to swallow every exception, set a [DEBUG] Exception in submit flash, and return {:ok, socket} — bypassing the user's on_error action. Now unexpected exceptions crash the LiveView per Phoenix conventions; validation failures still route through {:error, form_with_errors} to on_error.
  • optimistic_state/2 no longer evaluates async calculations synchronously. Calling the user's slow_fn inside render blocked mount and bypassed the AsyncResult flow.
  • Reactive graphs are no longer evaluated stale after recompile. See "Added: Compile-time graph cache invalidation."

Removed

  • Files: lib/lavash/argument.ex, lib/lavash/actions/update.ex, lib/lavash/actions/notify_parent.ex.
  • DSL entities: :derive, :update (the action op, not Ash's update), :notify_parent.
  • The <.o> display helper (deprecated long ago; ~L auto-wraps bare {@field} instead).
  • Pre-DSL data-optimistic-* attribute family (data-optimistic, -display, -field, -value). The current API is data-lavash-*, auto-injected by ~L.
  • The stale "Architecture" section in README that described socket.private.lavash as the state store — state lives in socket.assigns eagerly written by LSocket.put_state/3.
  • Five duplicate copies of extract_submit_errors/1, two of parse_value/parse_binding_value, two of resource_available?/1.
  • The process-dictionary side channel for component_states.