Skip to content

Hybrid components with ordinary Vue code - #15

Merged
dannote merged 6 commits into
masterfrom
hybrid-composables
Oct 5, 2026
Merged

dannote merged 6 commits into
masterfrom
hybrid-composables

Conversation

@dannote

@dannote dannote commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Why

A spike that used ordinary libraries in the demo's contacts page (es-toolkit's sortBy, VueUse's refDebounced, useLocalStorage, useClipboard, onKeyStroke) turned up three gaps:

  • The page returned 500. A computed reading a composable's result (debouncedSearch) or an import (sortBy) failed every server render with ExpressionError, and compiling said nothing.
  • Composable state was invisible. Only ref() counted as client state, so a file whose state came from composables compiled as server-only, and that state was never recorded for replay.
  • Callbacks in templates run in QuickBEAM on every render.

What changed

One commit each:

  • Left-out computeds, decided when compiling. Hybrid.Computeds.compile/2 decides, in dependency order, which computeds read only what the server has: refs, props, JS globals and other such computeds. The rest are left out with a warning naming what they lack. An expression reading a left-out computed is unrendered, and a v-if chain stops at an unrendered condition, so the first paint shows no branch rather than a misleading v-else.
  • Composable state is client state. ScriptSetup.client_bindings lists top-level names bound by any call other than Vue's compiler macros, ref(), computed() and macro imports, destructured names included. Such a binding makes a file hybrid.
  • Elixir counterparts for computeds. A def filtered(assigns) in <script lang="elixir"> computes filtered on the server on every render and in a replay. A counterpart named after a LiveView callback is a compile error.
  • Recording only what the render reads. Setup registers the client state that the template, or the computeds and counterparts it reads, read (a counterpart's from its patterns), refs and composables alike. The bridge reports the latest value of what changed once per PhoenixReplay's flush interval, so frame-rate state such as useMouse isn't recorded, and a replay accepts exactly those names.
  • State a render doesn't have (review): each hybrid render lists the composable state it lacks, all of it live, what a replay didn't record. Expressions reading it aren't rendered, a v-if chain stops at it, and an Elixir counterpart reading it isn't called (a counterpart matching %{sortKey: _} used to raise FunctionClauseError live).
  • Constants and counterpart calls (review): top-level constants are evaluated once while compiling and known to the server; a computed calling a script function with an Elixir counterpart calls the counterpart.
  • Recording (review): sources are unwrapped with unref and functions skipped, so useClipboard's copy is never called; pending changes go out when the page is hidden or navigates; left-out warnings carry the computed's line.
  • Docs. A "Composables and libraries" section in the Hybrid guide covers what the server has, left-out computeds and the v-if rule, counterparts and how they can drift, and callbacks in templates. Also the replay section, ARCHITECTURE.md and the CHANGELOG.

Verification

  • mix lint and mix test --include e2e: 356 tests. Fixtures from the spike assert the composable case and the import case, the first paint, counterparts live and in a replay (including one reading composable state, absent live), v-if on composable state, constants and counterpart calls in computeds, the reserved-name error, and the recorded names (state read only by a left-out computed isn't recorded).
  • Demo browser tests: a pending change going out when the page is hidden; recorded names on start, three keystrokes within one flush as one report of only search with its latest value, nothing before start or after stop, and a mount during a recording. The demo's other Playwright tests pass.
  • The spike applied to this branch: /contacts renders (200, previously 500) with a warning per left-out computed and no misleading "No contacts match". The demo rebuild on top of this is a separate PR.

…them

A computed reading a composable's result or an import failed every server
render with ExpressionError, and compiling said nothing. Computeds.compile
now decides, in dependency order, which computeds read only refs, props,
JS globals and other such computeds; the rest are left out with a warning
naming what they lack. Template expressions reading a left-out computed
are unrendered, and a v-if chain stops at an unrendered condition, so the
first paint shows no branch rather than a misleading v-else.
ScriptSetup.client_bindings lists top-level names bound by any call other
than Vue's compiler macros, ref(), computed() and macro imports, destructured
names included: composables whatever their name, reactive(), and so on. A
file with such a binding is hybrid, as one with a ref() is; one whose only
state came from composables used to compile as server-only.
A def in <script lang="elixir"> named after a computed, in snake_case,
taking the assigns, computes it on the server on every render and in a
replay, from the props, the refs, recorded or initial, and earlier
computeds. Such a computed is never left out, comes after the JavaScript
computeds, and counts as reading everything for change tracking. A
counterpart named after a LiveView callback is a compile error. Computed
names get their atoms while compiling, so rendering only looks them up.
Every ref was registered for replay, so state a template never reads, or
that changes at frame rate, would flood a recording. Hybrid.build now
works out what the render reads: the template's names, and through the
computeds and Elixir counterparts it reads (a counterpart's from its map
patterns and assigns.key access), refs and composables' results alike.
Setup registers those, the bridge unwraps them with Vue's toValue and
reports the latest value of what changed once per PhoenixReplay's flush
interval, and a replay accepts exactly those names.

The Hybrid guide explains composables and libraries: what the server has,
left-out computeds and the v-if rule, Elixir counterparts and how they can
drift, and callbacks in templates.
…ants

Each hybrid render lists the composable state it lacks: all of it live,
what a replay didn't record. An expression reading it isn't rendered, a
v-if chain whose condition reads it renders no branch, and an Elixir
counterpart reading it isn't called, its computed absent too; before, a
counterpart matching %{sortKey: _} raised FunctionClauseError on every
live render, and v-if="sortKey === 'name'" showed its v-else.

Top-level constants are evaluated once while compiling and known to the
server, and a computed calling a script function with an Elixir
counterpart calls the counterpart, so neither leaves a computed out.

Recording unwraps with unref and skips functions, such as useClipboard's
copy, which toValue would have called. Pending changes go out when the
page is hidden or navigates, before the replayer's last batch. Left-out
warnings point at the computed's line.
A counterpart whose pattern matches on a composable's value is never
called on the live render, so its computed was silently missing from
the first paint. Warn at its def, and make the convention explicit:
pattern keys and assigns.key are required, assigns[:key] is optional
and nil when missing. Optional reads are recorded too, so a replay has
them.
@dannote
dannote merged commit 29669cc into master Oct 5, 2026
2 checks passed
@dannote
dannote deleted the hybrid-composables branch October 5, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant