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.5] — 2026-05-25

Fixed

  • #19rx() dep extraction loses @field references nested inside the key of a bracket-access whose root isn't an @-ref. Example: rx(@a && (@b || @c)[@d]) lost :d from deps. The AST rewrite was correct (the calc evaluated to the right value when called fresh), but the reactive engine didn't track the missing dep — so the calc never recomputed when that field changed, and the cached value (often nil) leaked through.

    Worse than the rc.3 → rc.4 fix-target: this same shape used to raise a hard crash on Elixir 1.18, then rc.3 quieted the crash without finishing the dep walk, producing a silent wrong-result. rc.5 closes the loop.

[0.3.0-rc.4] — 2026-05-25

A round of compile-time validation to surface typos and stale references before they become runtime crashes or silent nil values. No runtime behaviour changes.

Added

  • Lavash.Transformers.ValidateDsl — a new transformer that runs after entity expansion and before compilation, raising Spark.Error.DslError with a clear hint when it spots:
    • duplicate state, calculate, or action names
    • a calculation whose name shadows a state of the same name
    • reads [:foo] where :foo matches no state, calc, read, prop, or form-derived field (previously a runtime KeyError)
    • set :foo, ... where :foo isn't a declared state (previously a silent socket-assign write)
    • set ..., rx(@field) or calculate :foo, rx(@field) where @field references an undeclared name (previously evaluated to nil silently)
    • action guards (action :foo, [], [:guard]) referencing names that aren't a state or calculation
  • <input field={@form[:typo]}> warning — the template transformer now logs a dev-only warning when the field name isn't an attribute of the Ash resource behind the form, listing the available attributes. Silent when the resource couldn't be loaded at compile time.

Fixed

  • The bare-{@field} non-optimistic diagnostic no longer fires for props in components. Props are constant for the component's lifetime — they don't have an optimistic flavour, and rendering them bare is the correct pattern.
  • bind={[child: :parent]} no longer warns when :parent is a declared prop on the host. all_state_fields in template metadata now includes prop entries alongside state.

[0.3.0-rc.3] — 2026-05-25

Two more adopter-feedback fixes, both real runtime crashes on recent Elixir versions and/or with idiomatic Elixir code.

Fixed

  • #17rx() handles @field references nested inside path-access keys and short-circuit operators. Two related bugs were fixed in one pass:
    • The walker rewrote @params in rx(@params["x"]) but left a key that was itself an @-ref (rx(@params[@k])) raw, so it survived into the stored AST and crashed at eval time with Module.get_attribute against an already-compiled module.
    • Kernel.&&/2, Kernel.||/2, Kernel.and/2, Kernel.or/2 expand eagerly on some Elixir versions (notably 1.18.x), hiding inner @field refs from the walker. Now pre-expanded only when needed so the walker sees a canonical shape across versions.
  • #18calculate :foo, rx(local_helper(@x)) resolves unqualified calls to local helpers — both def and defp — at runtime. Same root cause and fix shape as rc.2's #15 for action run fn bodies: each calculation's rx body is hoisted at compile time into a generated def __lavash_calc__/2 on the user's module, so local resolution (which covers defp) takes over. Public functions worked before via module.fun(...) qualification, but defp wasn't remote-callable and crashed.

Tooling

  • mix docs --warnings-as-errors runs as the fifth step of the optional pre-commit hook (.githooks/pre-commit). Catches Module.fun/n references in moduledocs / CHANGELOG that point at since-removed functions — those previously slipped through to published docs because mix docs exits 0 even with warnings.

[0.3.0-rc.2] — 2026-05-25

Five fixes prompted by a second round of adopter feedback. All five issues opened against the repo were closed.

Fixed

  • #12 — Action reads [:some_calc] now resolves correctly when :some_calc is a calculate :foo, rx(...) field. The runtime built the action's assigns map from declared state only, skipping derived values. Switched to LSocket.full_state/1 to match the rest of the runtime (which already used full_state); the previous behaviour was an inconsistency, not a deliberate restriction.
  • #13run fn bodies can now read non-Lavash socket assigns (@current_user set by AshAuthentication.on_mount, a tenant set by a plug, etc.) without re-declaring them as Lavash state. The assigns map is built from the full socket.assigns, with event params layered on top so phx-value-* still wins over a stray socket assign of the same name. Lifting auth-derived values into Lavash state via Lavash.Socket.put_state/3 remains the recommended pattern but is no longer required.
  • #15 — Unqualified calls to private helpers (defp helper(...)) inside run fn bodies now resolve at runtime. The body was previously evaluated via Code.eval_quoted + :erl_eval, which has no local function table; calls raised UndefinedFunctionError even though the compiler tracked the references (rc.1's #11 fix). Each run fn body is now hoisted into a generated def __lavash_run__(action_name, idx, assigns) on the user's module, so local helpers, aliases, imports, and module attributes are all in scope. The runtime invokes it via apply/3.
  • #16 — The auto-injector no longer wraps {@field} body expressions inside <textarea>, <option>, or <title>. Browsers treat these elements' body content as a value (submitted form data, displayed option label, page title), so the literal <span data-lavash-display="...">N</span> HTML was corrupting the form payload.

Added

  • #14 — New "Cookbook: a full form-submission recipe" section in the README. One end-to-end example showing use Lavash.LiveView + AshAuthentication on_mount + URL state with url_name: + ephemeral optimistic state bound to form inputs + calculate + custom mount/3 chaining into Runtime.mount/4 + Lavash.Socket.put_state/3 for seeding from auth + action :submit, [...] to read submit payload + a run fn calling a private helper.

Internal

  • Lavash.Action.Runtime.apply_runs/4 becomes apply_runs/5 (takes the action name). The runtime no longer uses Lavash.Rx.Cache.compile_run_fun/2; that function and its test are removed.

[0.3.0-rc.1] — 2026-05-25

A follow-up to 0.3.0-rc.0 that bumps to Phoenix LiveView 1.2-rc, adds a new template-declaration shape, and addresses four issues filed by the first external adopter.

Added

  • template do ~H"..." end template-declaration shape, alongside the existing render fn assigns -> ~L"..." end. Both compile to the same render/1 and go through the same auto-injection pipeline. The new shape uses the standard ~H sigil (no custom sigil required) and is the recommended way to declare templates going forward. The ~L shape stays supported and is still the only path that works with render_loading fn for animated overlays.
  • url_name: option on state from: :url so the URL key can differ from the field name (state :subject_handle, from: :url, url_name: "subject"). When a from: :url, required: false field falls back to its default and the configured key isn't in params, lavash logs a dev-only warning naming the missing key and what keys WERE present — turns silent typos into a debuggable signal.
  • Compile-time tracking of helper function references inside action run fn bodies. The bodies were previously captured as quoted AST, hiding helper-function call sites from the compiler. Builds with --warnings-as-errors no longer fail on defp helper/1 is unused warnings for helpers used only inside action bodies.
  • Lavash.SparkHeex spike: a Spark extension that treats HEEx templates as first-class DSL data. Transformers cross-validate @field refs against declared state entities, phx-click="event" refs against declared action entities, and phx-value-* keys against declared action params — all at compile time, raising Spark.Error.DslError with file/line on mismatch. Not yet integrated into the main DSL; lives as a parallel module.

Changed

  • mount/3 generated by use Lavash.LiveView is now defoverridable. Users who define their own def mount/3 to do per-route setup can chain into Lavash.LiveView.Runtime.mount/4 to attach the reactive graph. Previously the user's mount/3 silently shadowed the framework's, causing the first handle_params/3 to crash deep in Reactive.get_graph!.
  • phoenix_live_view dependency bumped to ~> 1.2-rc. LV 1.2 restructured Phoenix.LiveView.TagEngine into a behaviour plus Parser/Compiler/Tokenizer private modules. Lavash's previously 1828-line fork of TagEngine is now an 85-line shim that wraps the new upstream API. The token transformer (auto-injection) was rewritten to walk the new :block/:self_close node tree instead of a flat token list. Behaviour is unchanged.

Fixed

  • caller.function is set to a synthetic {:render, 1} when lavash compiles a template from a Spark transformer's module-define-time env, so LV 1.2's HTMLEngine.annotate_body/1 doesn't crash under debug_heex_annotations.
  • :strip_eex_comments is set on parser invocations so the compiler doesn't trip on {:eex_comment, _} nodes (which it only expects in whitespace filtering).
  • Lavash.Sigil.sigil_L/2 now calls Phoenix.LiveView.TagEngine.compile/2 directly. The deprecated EEx.compile_string(template, engine: ...) path no longer works on LV 1.2.

Documented (not fixed)

  • data-lavash-bind syncs asynchronously, so a fast tick-then-submit on a <form phx-submit="..."> can post before the bind reaches the server. The action body sees @confirmed = false even though the checkbox is visually checked. Two workarounds are documented in the README ("Forms vs. data-lavash-bind on submit"): prefer <.form for={@some_form}> for submit flows, or read the form params inside the action body via params [...] instead of through @field. A future release will close this gap.

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.