Skip to main content

CHANGELOG.md

# Changelog

All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [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`:
  ```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 ops**`push_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

- **#19**`rx()` 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

- **#17**`rx()` 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.
- **#18**`calculate :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.
- **#13**`run 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 end``set :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`.

[Unreleased]: https://github.com/u2i/lavash/compare/v0.4.0-rc.1...HEAD
[0.4.0-rc.1]: https://github.com/u2i/lavash/compare/v0.3.0-rc.5...v0.4.0-rc.1
[0.3.0-rc.0]: https://github.com/u2i/lavash/compare/v0.2.0...v0.3.0-rc.0