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.5 — 2026-06-08
Fixes the optimistic-template transpiler, which emitted invalid or
silently-broken client JS for several valid Elixir constructs — failures
that only surfaced at prod esbuild, never at mix compile/test. The
analysis now decides per expression whether it actually needs client
transpilation, and raises a clear compile-time error when an
optimistic-dependent expression can't be transpiled.
Fixed
?-suffixed keys on nested access (@matrix.declared?,r.spec_home?) emittedobj.declared?— invalid JS (esbuild "Unexpected ?"). They now route throughjs_field_access/2and emit quoted bracket keys:obj["declared?"]. (Top-level@-refs already did this; nested/loop-variable access bypassed it.)Empty-list comparison
x == []/x != []emittedx === []/x !== [], which in JS compares against a fresh array literal (always false / always true). Now emits.length === 0/.length > 0.
Added
Loop-aware optimistic analysis.
:for={x <- src}now bindsxas optimistic-derived only whensrcis optimistic. A loop variable over a static list is server-rendered (no client transpilation, no error); over an optimistic list it drives a client re-render.build_optimistic_namesnow also includes optimisticstatefields (previously only calculations/forms/actions), so:if/:for/{...}over optimistic state transpile as expected.Compile-time error for untranspilable optimistic expressions. When an expression depends on optimistic state but has no JS equivalent (a local helper call,
fn, an unsupported comprehension), lavash now raises aSpark.Error.DslErroratmix compilenaming the source line and the offending construct, with remedies (move it to acalculate/defrx, or drop the optimistic dependency to render it server-side). Previously this emitted invalid JS that broke prod esbuild, or a placeholder that silently dropped that part of the DOM.defrxhelpers are expanded before validation, so they are not flagged.
0.4.0-rc.4 — 2026-06-06
Templates are now declared with a single shape: template do ~H"..." end
(and template_loading do ~H"..." end for overlay loading states). The
legacy render fn assigns -> ~L"..." end / render_loading fn variant
and the ~L sigil are removed. This release also lands several
compile-time validations that turn common typo-class bugs into build
errors.
Removed
The
render fn assigns -> ~L"..." end/render_loading fnvariant and the~Lsigil.template do ~H"..." endis now the only way to declare a LiveView/Component template, andtemplate_loading do ~H"..." endthe only way to declare an overlay loading body. Internally the loading template now flows through the same token pipeline as the main render (previously it rode an escaped-fn path that depended on the~Lsigil). The deadLavash.Sigil,Lavash.Template.Compiled,Lavash.Render,Lavash.Component.Render, andLavash.Component.Templatemodules were deleted.Migration: replace
render fn assigns -> ~L\"\"\" <div>{@count}</div> \"\"\" endwith
template do ~H\"\"\" <div>{@count}</div> \"\"\" endand
render_loading fn assigns -> ~L"..." endwithtemplate_loading do ~H"..." end. Insidecomponents do component ...blocks, replace therender fn assigns -> ~H"..." endbody withtemplate do ~H"..." end.
Added
Compile-time validation of
phx-click/phx-submit/phx-changereferences. Static-string event values on plain HTML tags are cross-checked against the module's declared actions (and auto-generatedset_<name>setters fromstate ..., setter: true). A typo'dphx-click="incremnt"now raises aSpark.Error.DslErrorat compile time with a Levenshtein-based suggestion, instead of surfacing as a silent no-op in the browser. Dynamic expressions likephx-click={JS.dispatch(...)}and events on component nodes are skipped — the validator only checks references it can statically resolve.Compile-time validation of
phx-value-*attribute names against actionparamslists.<button phx-click="bump_by" phx-value-amout="5">now raises at compile time if:bump_bydeclaredparams [:amount], with a suggestion tophx-value-amount. Catches typos whose runtime symptom was a silently-nil param — often crashing later insideString.to_integer(nil). Auto-generatedset_<name>setters accept onlyphx-value-value.Compile-time validation of
@nameassign references in HEEx. Every@namein a template ({@field},:if={@open},class={@theme},<%= @count %>, etc.) must resolve to a declaredstate,prop,slot,calculate,async, orformon the module — or to a Phoenix-injected assign (@flash,@socket,@live_action,@uploads,@streams,@id,@myself,@inner_block). Typos like{@flsh[:info]}or:if={@opn}now raise aSpark.Error.DslErrorat compile time with a Levenshtein-based suggestion.?-suffix field names (e.g.state :is_admin?, :boolean) are matched correctly.Migration note: on_mount hooks that inject assigns previously worked implicitly — the assigns landed on
socket.assignsand were available in templates without lavash knowing. With this validation in place, hook-injected assigns must be declared viastate :name, :type, from: :assigns, assigns_key: :nameto be referenced in HEEx. This makes the source of every template assign explicit and gives the field a place in the reactive graph.
0.4.0-rc.3 — 2026-05-25
The optimistic JS pipeline was broken in rc.1/rc.2 — lavash's client-side optimistic patches never reached the browser. The end-state-only browser tests masked it because server reconciliation arrives within the 5s default assertion timeout, indistinguishable from a working optimistic patch. This release fixes the pipeline, restores client-side optimistic behaviour, and reworks the test infrastructure so silent regressions of this class can't slip through again.
Fixed
Colocated JS extraction silently deleted. Lavash's
ExtractColocatedJstransformer was writing per-module optimistic JS via rawFile.write!and returning a 2-tuple from__phoenix_macro_components__/0. Phoenix'sColocatedAssets.compile/0pass expects%Entry{}structs; finding none, it considered lavash's files orphaned and deleted them on every compile. Net effect on rc.1/rc.2: optimistic action fns andcalculate :foo, rx(...)JS never reached the browser. Every "optimistic" click was actually a server round-trip.Fix: use Phoenix.LiveView.ColocatedAssets.extract/5, which writes the file AND returns the proper
Entry. The persisted Entry flows through__phoenix_macro_components__/0; Phoenix tracks it; file survives.<input type="checkbox" data-lavash-bind="...">wrote the wrong value.form_handler.handleInputunconditionally readtarget.value, which for a checkbox is the staticvalue=""attribute regardless of checked-state. Ticking a checkbox withvalue="true"wrote the string"true"to optimistic state on both check AND uncheck — and downstreamdata-lavash-enabled === truestrict checks (and any boolean-typed reactive graph) silently failed.Fix: read
target.checkedfor checkbox,target.selectedOptionsfor<select multiple>, and skip change events on radios wheretarget.checked === false(so the unchecked sibling doesn't overwrite the newly-selected radio's value).[head | tail]cons-prepend produced[undefined]in JS. Anyrx()body using list-cons syntax (e.g.set :items, rx([@name | @items])) emitted[undefined]in the transpiled JS because the cons AST node fell through to the untranspilable fallback. At runtime, the array became[null]after JSON serialization.Fix: detect cons nodes inside list literals and emit JS spread.
[@name | @items]becomes[name, ...items]; multi-head[a, b | c]becomes[a, b, ...c].Modal reopen during exit animation silently failed. A user closing a modal and immediately reopening it before the 200ms exit animation completed left the modal closed. The SyncedVar's
_closeProtectionflag was being re-armed in_serverSetAnimatedafterIdlePhase.onEnterhad cleared it, causing the server's eventual reopen diff to be rejected as "stale."Fix: clear
_closeProtectioninIdlePhase.onEnter(the close is fully done; future server values are fresh), and remove the redundant re-set in_serverSetAnimated. The setOptimistic+idle-clear pair already provides the bounce protection.?-suffix field names produced invalid JS. Elixir allows?in atom names (idiomatic for booleans like:is_admin?), but JS identifiers don't. The transpiler emittedstate.is_admin?and{is_admin?: ...}, bothSyntaxErrors.Fix: new
Lavash.Rx.Transpiler.js_field_access/2andjs_field_key/1helpers wrap unsafe names with bracket access / string-key form.state["is_admin?"],{"is_admin?": ...}. Applied across the transpiler, action set deltas, map_by transforms, calc emissions, attr/subtree derive emissions, and action method declarations.
Added — test infrastructure
Phoenix.LiveView.ColocatedJS wired into the lavash test app.
test/support/endpoint.exnow serves the colocated manifest at/assets/phoenix-colocated/lavash; the test layout imports theoptimisticexport and assigns towindow.Lavash.optimisticso the JS pipeline'sloadGeneratedFunctionscan dispatch by module name. Without this load, the e2e tests verified only server reconciliation arrival, not the optimistic phase.Latency tests tightened to actually verify optimistic timing.
test/integration/latency_test.exsnow uses@moduletag wallabidi_timeout: 100— under the 400ms latency simulator, assertions can only pass via the client-side patch. If the optimistic path silently breaks again (colocated JS pipeline regression, transpiler bug, hook init failure), these tests fail within 100ms instead of passing 800ms later via server reconciliation.Regression test for the checkbox-bind value bug.
test/integration/checkbox_bind_test.exsasserts the bind writes the booleantrue(not the string"true") after a click. UsesWallabidi.Remote.Protocol.evalto inspecthook.statedirectly.
0.4.0-rc.2 — 2026-05-25
Added
from: :assigns— a new state source that lifts a value anon_mounthook put onsocket.assignsinto lavash state. Closes the AshAuthentication integration gap: the on_mount assigns:current_user, and lavash can now expose it torx()without a custommount/3escape hatch.on_mount {AshAuthentication.LiveView, :live_user_required} state :user, :map, from: :assigns, assigns_key: :current_user calculate :greeting, rx("Hello, " <> @user.name)One-way read: lavash mutations don't propagate back to
socket.assigns. Missing assign falls back todefault:.assigns_key:defaults to the field name. LV-only (not Lavash.Component — components receive parent data via props).
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
LavashOptimisticJS 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 bylavash({ 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
LavashOptimistichook name still exists in markup (the server runtime emitsphx-hook="LavashOptimistic");getHooks()registers the decorator under that name. User hooks passed togetHooksare auto-decorated — lavash activates only on elements carryingdata-lavash-state(zero cost on user hooks elsewhere).
Added — DSL capabilities
messages do message :name do ... end end— declarativehandle_infocapability. Body is an op-sequence (run/effect/set/fire) drawn from the same vocabulary as action bodies. Replaces the escape hatch of customhandle_info/2for PubSub broadcasts, self-scheduled timers, monitor messages.components do component :name do ... end end— block-structured function-component DSL usingprop/slot/render fn. Replaces the positionalattr/slotmacros 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 throughPhoenix.LiveView.start_async/3sorender_async/2in tests sees it.fire :fooop — triggers anasyncdeclaration. Available inside action bodies, message bodies, and the newmount doblock. One declaration, multiple trigger paths.mount do <ops> end— lifecycle block symmetric withmessages do. Op-sequence body. Replaces the implicit "auto-fire at mount" default with explicit firing.when_connected do <ops> endinsidemount— guard for ops that only run on the websocket mount (subscribe to PubSub, schedule timers, etc.). Replaces inlineif Phoenix.LiveView.connected?(socket) do ... end.Action body ops —
push_patch,redirect,push_eventavailable alongsideset/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/3handle_params/3handle_info/2render/1+ slotson_mountchainsassign_async/3+start_async/3+handle_async/3- Cross-module functional components (the new
components doblock) - LiveComponents (stateful child views)
Still pending: streams, uploads, terminate/2, format_status/2.
Added — JS pipeline architecture
lavash({ concerns })factory inpriv/static/lavash.js— returns a decorator that wraps any user hook. Auto-activates on elements withdata-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.defaultConcernsexport — the standard bundle of all four.9-stage update pipeline with documented
ctxschema. Seepriv/static/PIPELINE.mdfor the architecture: stage names, ctx fields, concern interface, merge visitor protocol.Visitor-based merge walker at
priv/static/merge_walker.js— replaces the inlinemergeServerStatemethod on the old hook. Concerns register handlers keyed by path pattern (emptyParams,serverErrors,animatedPhaseField,paramsCleared,skipServerErrorClear).data-modal-phase/data-flyover-phaseattributes — 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.exsexercising optimistic UI under simulated latency. Uses LiveView's built-inenableLatencySim()+ wallabidi 0.4.0-rc.3'swith_latency/click(q, await: :defer)/await_patch. Covers the scalar/boolean/array optimistic paths and 9 of 11 modal phase machine transitions.ModalAsyncComponentfixture at/magic/modal-async-host— modal withasync_assign :itemfor testing the entering → loading → visible branch.
Changed
Default e2e driver switched from Lightpanda to Chrome CDP. Lightpanda doesn't reliably fire
phx-hookmounted()callbacks in our test setup — the optimistic patch fromLavashOptimisticnever ran, so latency tests silently observed only the server-reconciled state. Chrome CDP works correctly.Wallabidi upgraded to
0.4.0-rc.3. Addswith_latency/3,await: :deferon interaction primitives, andawait_patch/2.Mount lifecycle runs via a generated
__lavash_mount_lifecycle__/1hook called from insideLavash.LiveView.Runtime.mount/4— so a user-overriddenmount/3(still needed for things liketemporary_assigns:) still picks upmount do ... endblock ops.
Fixed
removeEventListenerin the old hook'sdestroyed()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.warncalls were diagnostic state-machine breadcrumbs polluting normal dev tools output. Downgraded toconsole.debug. The one genuine warning (thedata-lavash-animatedparse-failure message) stays asconsole.warn.
[0.3.0-rc.5] — 2026-05-25
Fixed
#19 —
rx()dep extraction loses@fieldreferences nested inside the key of a bracket-access whose root isn't an @-ref. Example:rx(@a && (@b || @c)[@d])lost:dfrom 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 (oftennil) 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, raisingSpark.Error.DslErrorwith a clear hint when it spots:- duplicate
state,calculate, oractionnames - a calculation whose name shadows a state of the same name
reads [:foo]where:foomatches no state, calc, read, prop, or form-derived field (previously a runtimeKeyError)set :foo, ...where:fooisn't a declared state (previously a silent socket-assign write)set ..., rx(@field)orcalculate :foo, rx(@field)where@fieldreferences an undeclared name (previously evaluated to nil silently)- action guards (
action :foo, [], [:guard]) referencing names that aren't a state or calculation
- duplicate
<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:parentis a declared prop on the host.all_state_fieldsin 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
- #17 —
rx()handles@fieldreferences nested inside path-access keys and short-circuit operators. Two related bugs were fixed in one pass:- The walker rewrote
@paramsinrx(@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 withModule.get_attribute against an already-compiled module. Kernel.&&/2,Kernel.||/2,Kernel.and/2,Kernel.or/2expand eagerly on some Elixir versions (notably 1.18.x), hiding inner@fieldrefs from the walker. Now pre-expanded only when needed so the walker sees a canonical shape across versions.
- The walker rewrote
- #18 —
calculate :foo, rx(local_helper(@x))resolves unqualified calls to local helpers — bothdefanddefp— at runtime. Same root cause and fix shape as rc.2's #15 for actionrun fnbodies: each calculation's rx body is hoisted at compile time into a generateddef __lavash_calc__/2on the user's module, so local resolution (which coversdefp) takes over. Public functions worked before viamodule.fun(...)qualification, butdefpwasn't remote-callable and crashed.
Tooling
mix docs --warnings-as-errorsruns as the fifth step of the optional pre-commit hook (.githooks/pre-commit). CatchesModule.fun/nreferences in moduledocs / CHANGELOG that point at since-removed functions — those previously slipped through to published docs becausemix docsexits 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_calcis acalculate :foo, rx(...)field. The runtime built the action's assigns map from declared state only, skipping derived values. Switched toLSocket.full_state/1to match the rest of the runtime (which already usedfull_state); the previous behaviour was an inconsistency, not a deliberate restriction. - #13 —
run fnbodies can now read non-Lavash socket assigns (@current_userset byAshAuthentication.on_mount, a tenant set by a plug, etc.) without re-declaring them as Lavash state. The assigns map is built from the fullsocket.assigns, with event params layered on top sophx-value-*still wins over a stray socket assign of the same name. Lifting auth-derived values into Lavash state viaLavash.Socket.put_state/3remains the recommended pattern but is no longer required. - #15 — Unqualified calls to private helpers (
defp helper(...)) insiderun fnbodies now resolve at runtime. The body was previously evaluated viaCode.eval_quoted+:erl_eval, which has no local function table; calls raisedUndefinedFunctionErroreven though the compiler tracked the references (rc.1's #11 fix). Eachrun fnbody is now hoisted into a generateddef __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 viaapply/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+ AshAuthenticationon_mount+ URL state withurl_name:+ ephemeral optimistic state bound to form inputs +calculate+ custommount/3chaining intoRuntime.mount/4+Lavash.Socket.put_state/3for seeding from auth +action :submit, [...]to read submit payload + arun fncalling 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"..." 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.