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.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"..." endtemplate-declaration shape, alongside the existingrender fn assigns -> ~L"..." end. Both compile to the samerender/1and go through the same auto-injection pipeline. The new shape uses the standard~Hsigil (no custom sigil required) and is the recommended way to declare templates going forward. The~Lshape stays supported and is still the only path that works withrender_loading fnfor animated overlays.url_name:option onstate from: :urlso the URL key can differ from the field name (state :subject_handle, from: :url, url_name: "subject"). When afrom: :url, required: falsefield 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 fnbodies. The bodies were previously captured as quoted AST, hiding helper-function call sites from the compiler. Builds with--warnings-as-errorsno longer fail ondefp helper/1 is unusedwarnings for helpers used only inside action bodies. Lavash.SparkHeexspike: a Spark extension that treats HEEx templates as first-class DSL data. Transformers cross-validate@fieldrefs against declaredstateentities,phx-click="event"refs against declaredactionentities, andphx-value-*keys against declared action params — all at compile time, raisingSpark.Error.DslErrorwith file/line on mismatch. Not yet integrated into the main DSL; lives as a parallel module.
Changed
mount/3generated byuse Lavash.LiveViewis nowdefoverridable. Users who define their owndef mount/3to do per-route setup can chain intoLavash.LiveView.Runtime.mount/4to attach the reactive graph. Previously the user'smount/3silently shadowed the framework's, causing the firsthandle_params/3to crash deep inReactive.get_graph!.phoenix_live_viewdependency bumped to~> 1.2-rc. LV 1.2 restructuredPhoenix.LiveView.TagEngineinto a behaviour plusParser/Compiler/Tokenizerprivate 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_closenode tree instead of a flat token list. Behaviour is unchanged.
Fixed
caller.functionis set to a synthetic{:render, 1}when lavash compiles a template from a Spark transformer's module-define-time env, so LV 1.2'sHTMLEngine.annotate_body/1doesn't crash underdebug_heex_annotations.:strip_eex_commentsis 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/2now callsPhoenix.LiveView.TagEngine.compile/2directly. The deprecatedEEx.compile_string(template, engine: ...)path no longer works on LV 1.2.
Documented (not fixed)
data-lavash-bindsyncs 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 = falseeven though the checkbox is visually checked. Two workarounds are documented in the README ("Forms vs.data-lavash-bindon submit"): prefer<.form for={@some_form}>for submit flows, or read the form params inside the action body viaparams [...]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
deriveDSL block.derive :name do argument ... run/compute endno longer compiles. Usecalculate :name, rx(...)instead — the modern form already does whatderivedid, plus transpiles to JS for the optimistic path. Migration is mechanical: convert eachargumentdeclaration to a state/derive reference inside therx(...)body. - Removed the
update :field, fnaction operation. Useset :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 end→set :n, rx((@n || 0) + 1)
- Removed the
notify_parent :eventaction operation. No usage anywhere in this repo's demo or fixtures, andbind=+ PubSub cover the state-sync and cross-process cases. For the genuine "child fires a callback on its parent" case, usesend(self(), {:my_event, payload})inside arun fnand pattern-match inhandle_info/2. - Removed
Lavash.Argument(the derive-specific argument struct). Read-side arguments useLavash.Read.Argumentand are unaffected. - Removed
Lavash.Actions.UpdateandLavash.Actions.NotifyParentstructs, 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_sethandler is now reached via the new envelope. ~Lis required for Lavash LiveView and Component render functions —~Hworks 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 intest/support/moved toLavash.Test.Magic.*(DSL fixtures) and a newLavash.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+ areactive do state ... derive ... endblock gets you the reactive graph withput_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
~Ltemplate transformer:- Warn when a bare
{@field}references a declared-but-non-optimistic state field (likely missingoptimistic: true). - Warn when
bind={[child: :parent]}targets a:parentthat isn't a declared state field on the host.
- Warn when a bare
- Auto-injection of more
data-lavash-*attributes from natural Phoenix HEEx patterns:class={if @bool, do: A, else: B}on an optimistic boolean → auto-injectsdata-lavash-toggle.class={if val in @list, do: A, else: B}on an optimistic array → auto-injectsdata-lavash-member+data-lavash-member-value.<.lavash_component bind={[n: :p]}>→ auto-injectsn={@p}so the child receives the parent's value.
- Compile-time graph cache invalidation.
Lavash.Dsl.Graph,Lavash.Reactive, andLavash.Rx.Cacheregister@after_compilehooks that drop their:persistent_termcache 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 inrx()expressions andrunfunctions are compiled once and stored in:persistent_term, replacing the per-fireCode.eval_quotedcost. - 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.MissingRequiredFieldErrorstructured 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-duplicatedparse_value/1andparse_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:actionsaccessor 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_infoon LiveViews andupdate/handle_eventon Components now havedefoverridabledeclared, so users can write their own clauses and callsuper/Nto fall through to the lavash dispatch. :lavash_*catch-all logs. Thehandle_infocatch-all inLavash.LiveView.Runtimelogs a warning when an unrecognized:lavash_*tuple arrives instead of silently dropping it. Library bugs surface in the log instead of vanishing.component_statesrouted 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_entitiesfor compile time,module.__lavash__/1for the canonical runtime accessor, andSpark.Dsl.Extension.get_entities/2as an escape hatch for the few callers that need un-augmented entities. - Phoenix.HTML.FormData protocol on
Lavash.Formnow implementsto_form/4,input_value/3, andinput_validations/3. Nested form inputs (<.inputs_for>) now work end-to-end. - Default Elixir/OTP versions pinned.
.tool-versionsdeclareselixir 1.19.0-otp-28/erlang 28.1. - Pre-commit hook ships in
.githooks/. Opt in withgit 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 --strictwith no findings; the pre-commit hook keeps it that way.
Fixed
apply_runs/4was silently no-op for any non-initial action.Phoenix.Component.assignstores eithertrue(initial render) or the old value (subsequent change) in__changed__; the old code pattern-matched only on the literaltrueand 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:tagtoken with no matching:close, so they wrongly consumed the depth-0 slot when scanning back through the accumulator. Now skipped viaclosing: :void/closing: :selfin tag meta.Rx.qualify_local_callsbroke imported helpers. Bare calls were rewritten toCaller.fn(...)regardless of whether the name was imported from another module. Now consults__CALLER__.functions/.macrosand qualifies against the import's source module.- Silent
try/rescuearoundapply_submitsremoved. Used to swallow every exception, set a[DEBUG] Exception in submitflash, and return{:ok, socket}— bypassing the user'son_erroraction. Now unexpected exceptions crash the LiveView per Phoenix conventions; validation failures still route through{:error, form_with_errors}toon_error. optimistic_state/2no longer evaluates async calculations synchronously. Calling the user'sslow_fninside 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;~Lauto-wraps bare{@field}instead). - Pre-DSL
data-optimistic-*attribute family (data-optimistic,-display,-field,-value). The current API isdata-lavash-*, auto-injected by~L. - The stale "Architecture" section in README that described
socket.private.lavashas the state store — state lives insocket.assignseagerly written byLSocket.put_state/3. - Five duplicate copies of
extract_submit_errors/1, two ofparse_value/parse_binding_value, two ofresource_available?/1. - The process-dictionary side channel for
component_states.