Skip to content

Declare data the browser changes with defineModel, not prop writes - #17

Merged
dannote merged 1 commit into
masterfrom
define-model
Oct 5, 2026
Merged

dannote merged 1 commit into
masterfrom
define-model

Conversation

@dannote

@dannote dannote commented Oct 5, 2026

Copy link
Copy Markdown
Member

Breaking: hybrid components declare data the browser changes, and the server owns, with Vue's defineModel instead of writing to props.

Why

A server action wrote props.contacts = .... Vue rejects that in its types (props are read-only, TS2540) and at runtime; it worked only because the client codegen rewrote the assignment. Hybrid components therefore couldn't type-check, and the convention taught something Vue forbids.

What changes

<script setup lang="ts">
const contacts = defineModel<Contact[]>("contacts", { required: true })

function deleteContact(id: number) {
  "use server"
  contacts.value = contacts.value.filter((c) => c.id !== id)
}
</script>
  • Models. defineModel("name") is assigned by the LiveView under that name, sent to the browser with the props, and read as .value in the script, including in computeds the server evaluates. A model must be named after its variable. A function that writes a model is a server action, as one that wrote a prop was.
  • The body runs as written. The body runs in the browser, so a model write updates the mount's props at once through its update: listener. Then the action goes to the server through a new bridge.action/2. A return or a throw first sends nothing, so a guard validates in the browser.
  • Rollback. LiveView replies only after applying the action's diff; in the reply, the bridge reapplies data-pv-props. An assign the server left unchanged therefore replaces the optimistic value; before, it stayed on screen.
  • Props read by actions are sent. Because action bodies run in the browser, the props they read are client props. A value the browser must never see isn't a prop; handle_event/3 reads it. The guide says so.
  • Compile errors. Writing props.x, or a bare x for a prop the script never binds, is a compile error at its line, pointing at defineModel. JS.FreeNames.occurrences/1 finds the unbound writes, so a parameter named like a prop is fine.
  • Fixed. A server action whose body returned early was parsed as a script, failed, and silently became a client handler, so it never reached the server.

Docs

The Hybrid guide's Server actions section, ARCHITECTURE, docs/hybrid-architecture.md, and CHANGELOG (Breaking changes, Fixed).

Tests

  • Model integration tests: the server render, the classification, the generated client, and the compile errors.
  • The QuickBEAM e2e test runs the generated client in Vue: a model write updates the props, the action is sent once, and a guard keeps a second click in the browser.
  • The demo (next PR) uses models throughout. Its browser tests include a declined save that snaps back, and that test fails with the rollback disabled.
  • mix lint clean; mix test --include e2e: 373 passed.

A hybrid server action wrote props.x, which Vue's types and runtime both
reject; it worked only because the client codegen rewrote the
assignment. Data the browser changes and the server owns is now a model,
Vue's construct for it: defineModel("contacts") is assigned by the
LiveView, read as .value in the script, and writing it is a server action.

The action's body runs in the browser as written, so a model write
updates the mount's props at once through its update: listener. Then the
action goes to the server, unless the body returned or threw first, so a
guard validates in the browser. Once the server has handled it, the
bridge reapplies data-pv-props, so a change the server declined goes
away. Props an action reads are sent to the browser.

Writing props.x, or a bare prop name the script never binds, is a
compile error pointing at defineModel; a model must be named after its
variable. A server action body with an early return was parsed as a
script, failed, and silently became a client handler; bodies now parse
as functions.
@dannote
dannote merged commit 0ce5c6d into master Oct 5, 2026
2 checks passed
@dannote
dannote deleted the define-model branch October 5, 2026 17:31
@dannote dannote mentioned this pull request Oct 5, 2026
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