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

Unreleased

0.4.0-rc.1 — 2026-05-25

The 0.4 line starts here. Two big shifts since rc.5: lavash now has DSL coverage for the LiveView callback surface (parity work against vanilla LV), and the JS hook is replaced by a composable concern pipeline that auto-decorates user hooks.

Breaking

  • LavashOptimistic JS hook removed. It was a monolithic Phoenix hook (~470 lines doing optimistic state, modal/flyover animation, form input tracking, parent↔child component bindings, click interception). Replaced by lavash({ concerns: [...] }) — a decorator factory built around a concern pipeline.

    Migration in app.js:

    // Before
    import { LavashOptimistic, SyncedVar, OverlayAnimator } from "lavash";
    window.Lavash = window.Lavash || {};
    window.Lavash.SyncedVar = SyncedVar;
    window.Lavash.OverlayAnimator = OverlayAnimator;
    const liveSocket = new LiveSocket("/live", Socket, {
      params: { _csrf_token: token },
      hooks: { LavashOptimistic }
    });
    
    // After
    import { lavash, defaultConcerns, getHooks, getState } from "lavash";
    const lavashDecorator = lavash({ concerns: defaultConcerns });
    const liveSocket = new LiveSocket("/live", Socket, {
      params: () => ({ _csrf_token: token, _lavash_state: getState() }),
      hooks: getHooks(lavashDecorator, MyAppHooks)
    });

    The LavashOptimistic hook name still exists in markup (the server runtime emits phx-hook="LavashOptimistic"); getHooks() registers the decorator under that name. User hooks passed to getHooks are auto-decorated — lavash activates only on elements carrying data-lavash-state (zero cost on user hooks elsewhere).

Added — DSL capabilities

  • messages do message :name do ... end end — declarative handle_info capability. Body is an op-sequence (run/effect/set/fire) drawn from the same vocabulary as action bodies. Replaces the escape hatch of custom handle_info/2 for PubSub broadcasts, self-scheduled timers, monitor messages.

  • components do component :name do ... end end — block-structured function-component DSL using prop/slot/render fn. Replaces the positional attr/slot macros from Phoenix.Component, which attach to the next-defined function (refactor-fragile). The block form makes the function-to-schema relationship explicit.

  • async :foo do run fn assigns -> ... end end — declares a triggerable async task. Lands as %Phoenix.LiveView.AsyncResult{} on assigns. Routed through Phoenix.LiveView.start_async/3 so render_async/2 in tests sees it.

  • fire :foo op — triggers an async declaration. Available inside action bodies, message bodies, and the new mount do block. One declaration, multiple trigger paths.

  • mount do <ops> end — lifecycle block symmetric with messages do. Op-sequence body. Replaces the implicit "auto-fire at mount" default with explicit firing.

  • when_connected do <ops> end inside mount — guard for ops that only run on the websocket mount (subscribe to PubSub, schedule timers, etc.). Replaces inline if Phoenix.LiveView.connected?(socket) do ... end.

  • Action body opspush_patch, redirect, push_event available alongside set/run/effect/fire/navigate/emit.

Added — LV callback parity coverage

Paired test fixtures exercising each LiveView callback against both vanilla Phoenix.LiveView and lavash. Lavash DSL covers the callback's intent declaratively; the test asserts both expressions produce the same observable behaviour. Coverage:

  • mount/3 + state from: :session + temporary_assigns:
  • handle_event/3
  • handle_params/3
  • handle_info/2
  • render/1 + slots
  • on_mount chains
  • assign_async/3 + start_async/3 + handle_async/3
  • Cross-module functional components (the new components do block)
  • LiveComponents (stateful child views)

Still pending: streams, uploads, terminate/2, format_status/2.

Added — JS pipeline architecture

  • lavash({ concerns }) factory in priv/static/lavash.js — returns a decorator that wraps any user hook. Auto-activates on elements with data-lavash-state; no-ops on others.

  • Four concern modules at priv/static/concerns/: optimisticActions, bindings, forms, overlays. Each is a plain object with optional named stage handlers + optional merge visitors. The user picks which to include.

  • defaultConcerns export — the standard bundle of all four.

  • 9-stage update pipeline with documented ctx schema. See priv/static/PIPELINE.md for the architecture: stage names, ctx fields, concern interface, merge visitor protocol.

  • Visitor-based merge walker at priv/static/merge_walker.js — replaces the inline mergeServerState method on the old hook. Concerns register handlers keyed by path pattern (emptyParams, serverErrors, animatedPhaseField, paramsCleared, skipServerErrorClear).

  • data-modal-phase / data-flyover-phase attributes — surfaced server-side and updated client-side from the SyncedVar phase machine. Makes phase transitions directly observable in the DOM for tests.

Added — Test infrastructure

  • Latency-aware e2e safety net — 10 tests under test/integration/{latency,panel_latency}_test.exs exercising optimistic UI under simulated latency. Uses LiveView's built-in enableLatencySim() + wallabidi 0.4.0-rc.3's with_latency / click(q, await: :defer) / await_patch. Covers the scalar/boolean/array optimistic paths and 9 of 11 modal phase machine transitions.

  • ModalAsyncComponent fixture at /magic/modal-async-host — modal with async_assign :item for testing the entering → loading → visible branch.

Changed

  • Default e2e driver switched from Lightpanda to Chrome CDP. Lightpanda doesn't reliably fire phx-hook mounted() callbacks in our test setup — the optimistic patch from LavashOptimistic never ran, so latency tests silently observed only the server-reconciled state. Chrome CDP works correctly.

  • Wallabidi upgraded to 0.4.0-rc.3. Adds with_latency/3, await: :defer on interaction primitives, and await_patch/2.

  • Mount lifecycle runs via a generated __lavash_mount_lifecycle__/1 hook called from inside Lavash.LiveView.Runtime.mount/4 — so a user-overridden mount/3 (still needed for things like temporary_assigns:) still picks up mount do ... end block ops.

Fixed

  • removeEventListener in the old hook's destroyed() bound fresh function references, so listeners were never actually removed — they leaked across hook teardowns. The new concern modules stash bound refs at mount and use the same refs at destroy.

  • Console clutter: 26 of 27 console.warn calls were diagnostic state-machine breadcrumbs polluting normal dev tools output. Downgraded to console.debug. The one genuine warning (the data-lavash-animated parse-failure message) stays as console.warn.

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