Skip to content

v0.6.0

Choose a tag to compare

@dannote dannote released this 05 Oct 22:08
· 27 commits to master since this release

Upgrading from 0.5

A hybrid server action no longer writes props. Declare the data it changes as a model, and write the model's .value:

-const props = defineProps(["contacts"])
+const contacts = defineModel("contacts")

 function deleteContact(id) {
   "use server"
-  props.contacts = props.contacts.filter((c) => c.id !== id)
+  contacts.value = contacts.value.filter((c) => c.id !== id)
 }

Read the model as contacts.value in the script and as contacts in the template; the LiveView assigns it as contacts, as before. The compiler reports each remaining props.x = ... with its line. The body of a server action now runs in the browser, so a guard (if (!id) return) keeps it from reaching the server, and a prop it reads is sent to the browser. See Breaking changes below and the Hybrid guide's Server actions section.

Breaking changes

  • Require Volt 0.21. Its mix volt.js.check type-checks a .vue file's scripts with the project's tsconfig.json, path aliases and declaration files included, and leaves <script lang="elixir"> out, so hybrid components type-check as written.
  • Hybrid components declare data the browser changes, and the server owns, with Vue's defineModel("name") instead of writing to props. A server action writes the model's .value: contacts.value = contacts.value.filter(...) in place of props.contacts = .... Writing props.x, or a bare x for a prop the script doesn't declare, is a compile error that points at defineModel, since props are read-only in Vue, in its types and at runtime; the old convention only worked because the compiler rewrote the assignment. A model must be named after its variable (const open = defineModel("open")), the assign the LiveView sets. A function that writes a model is a server action, as one that wrote a prop was.
  • A server action's body now runs in the browser as written, and the action is sent afterwards, unless the body returns or throws first, so a guard such as if (!dirty.value) return validates in the browser. Before, only its prop assignments ran and the event always went. So a prop a server action reads is now sent to the browser; one the browser must never see, such as a token, isn't a prop, and handle_event/3 reads it instead.
  • Reactive mode and the full runtime keep their QuickBEAM runtime in socket.private instead of the :__vapor_runtime__ and :__vue_runtime__ assigns, so a session recorder doesn't record a pid and render/1 reads only state.
  • PhoenixVapor.Component and its vue/1 macro, which returned its argument unchanged, are removed; use PhoenixVapor imports only the ~VUE sigil. Use ~VUE directly.
  • Template expressions follow JavaScript's semantics, as Vue renders them in the browser: truthiness (0, "" and NaN are falsy in v-if, ? :, !, && and ||), === (1 === 1.0), ==, relational comparison (undefined > 0 is false), + concatenation and arithmetic with NaN and Infinity, and typeof null. A missing assign is undefined and nil is null. Before, Elixir's semantics leaked through, so v-if="items.length > 0" rendered with no items.
  • Modules are grouped by role: PhoenixVapor.Compiler.* compiles templates, PhoenixVapor.Renderer.* renders them, and each mode's modules sit under it. PhoenixVapor.Runtime is now PhoenixVapor.Reactive.Runtime, PhoenixVapor.LiveVue is PhoenixVapor.Full, PhoenixVapor.VueRuntime is PhoenixVapor.Full.Runtime, and PhoenixVapor.ScriptSetup is PhoenixVapor.Compiler.ScriptSetup; the runtimes and ScriptSetup are internal. use PhoenixVapor, ~VUE, PhoenixVapor.Vue, PhoenixVapor.Template and PhoenixVapor.ExpressionError are unchanged.

Added

  • Elixir counterparts for hybrid computeds: a def filtered(assigns) in <script lang="elixir"> computes filtered on the server, from the props, the refs and earlier computeds, so a computed built on a composable or a library such as es-toolkit still renders in the first paint and in a replay. A key in the counterpart's pattern, or read as assigns.key, is required, and one read as assigns[:key] is optional, nil when the render doesn't have it; a counterpart that requires state only the browser has is never called on the live render, with a compile-time warning. A counterpart named after a LiveView callback, such as render, is a compile error.
  • A hybrid component's constants (const PAGE_SIZE = 20) are evaluated once while compiling and known to its server render, and a computed calling a script function with an Elixir counterpart calls the counterpart. State only the browser has, such as a composable's value, is absent from the live render: an expression reading it isn't rendered, a v-if chain whose condition reads it renders no branch, and a counterpart reading it isn't called, until a replay that recorded it.
  • A .vue file is hybrid when <script setup> declares state the browser owns: a ref(), or now any top-level name bound by a call the compiler can't run, such as a composable's (useLocalStorage, const { copy, copied } = useClipboard()) or reactive(). A file whose only state came from composables used to compile as server-only.
  • Session replay for hybrid components: while a session replayer such as PhoenixReplay records client state (phx_replay:start on window), each hybrid component reports the client state its server render reads, refs and composables' results alike, as phx_replay:state events: all of it on start, or on mount during a recording (<html data-phx-replay>), then the latest value of what changed, at most once per flush interval, under phoenix_vapor:<wrapper id>, with non-plain values as null; nothing is dispatched otherwise. Each hybrid LiveView defines replay_render/1, which renders without the client hook and with the state recorded in @phoenix_replay_state. PhoenixVapor needs no dependency on PhoenixReplay. See the Hybrid guide's Session replay section.
  • An expression only JavaScript can evaluate, such as items.filter(i => i.on) or Math.max(a, b), is reported at compile time as a warning: it runs in QuickBEAM on every render, while the rest of rendering is Elixir. A method a value doesn't have, such as trim() on a number, raises PhoenixVapor.ExpressionError, as it throws in the browser.
  • In hybrid mode, components from packages, such as Reka UI, render on the server when everything they receive is known at compile time. Vue's server renderer runs them once in QuickBEAM while the template compiles, so rendering runs no JavaScript. Nested package components render together, providers such as TooltipProvider render only their content, and in hybrid mode the initial values of refs count as known. One that can't render is reported with the reason, such as a prop known only when rendering or an error from the component. Outside hybrid mode, where nothing takes over in the browser, only package components that render just their content, such as providers, render; others remain a compile error.
  • A macro call that reads props known only when rendering runs at compile time once per combination of the values their TypeScript types allow, up to 64, and rendering looks the result up. Types come from TypeScript's own checker, from the project's node_modules, so variant types derived from a tailwind-variants config work. A value outside the type raises PhoenixVapor.ExpressionError.
  • A package component's props may be expressions of known values, such as :open="selected !== null" over a ref's initial value, and package parts inside the template's own v-for or v-if, such as a tooltip per row, render in their ancestors' context.
  • A template's call to a <script setup> function renders on the server through the function of the same name in snake_case that <script lang="elixir"> defines, such as role_tone/1 for roleTone(role). The full runtime now leaves a <script lang="elixir"> block out of its browser bundle.
  • PhoenixVapor.Volt, a Volt plugin that resolves phoenix_vapor imports from a path or umbrella dependency, which resolve_dirs: ["deps"] doesn't find.
  • The file: option and PhoenixVapor.Vue.component/2 take any expression known at compile time, such as Path.join(@templates, "Card.vue"), besides a path relative to the module's file.

Fixed

  • A server action sent the refs it read as they were after its body ran, so selectedIds.value = [] at the end of the body sent an empty selection. It sends them as they were when it was called.
  • While several server actions were in flight, the first answer reapplied the server's props, undoing the optimistic changes of the others until their own answers came. The props now apply once every action in flight has been answered, or has failed.
  • An optimistic change the server declined stayed on screen: when handle_event/3 left the assign unchanged, no new props came. Once the server has handled a server action, the component's props and models are the server's again.
  • A server action whose body returned early, such as if (!id) return, wasn't recognized: its body was parsed as a script, where return is a syntax error, so it was a client handler and never reached the server.
  • A hybrid component's prop that only the template read, as props.saved, stayed on the server: the browser never received it, and the server's own first paint rendered it empty. A prop the template reads now goes to the browser.
  • Reactive mode's direct DOM patching assumed the template was the LiveView's whole render, so inside a layout it matched the layout's slots instead of the template's, and updates didn't reach the page. It now finds the template in the rendered tree, and patches directly only when a diff changes nothing outside it.
  • A hybrid computed that read something only the browser has, such as a composable's result (refDebounced) or an import (sortBy from es-toolkit), failed every server render with ExpressionError. The server now decides this when compiling: it leaves such a computed, and what reads it, out of the first paint, with a warning naming what it lacks. An expression reading it isn't rendered, and a v-if chain whose condition reads it renders no branch, rather than its v-else.
  • The QuickBEAM runtime that ran macro calls while compiling was never stopped, and the one for package components leaked when compiling raised. One runtime per compile now serves both and stops in an after.
  • A hybrid component started a QuickBEAM runtime on every render to evaluate its computed values, and a computed that failed rendered as missing without a report. Computeds now evaluate as template expressions do: one that reads only refs is evaluated once while compiling, and package components can render with it; one that reads props is evaluated when rendering, on the LiveView process's runtime, with its last value reused while its inputs are unchanged. A computed's other computeds are evaluated first, so lists such as a filtered view of props render on the server. A failure is a compile-time warning, or an ExpressionError when rendering.
  • A template literal's text between expressions rendered empty: `a${x}b` gave 1.