Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

## 0.17.2 - 2026-10-09

### Fixed

- `split_template/2` rendered a static `ref` or `key` as an attribute, and passed `ref` and `key` to a component as props. Vue reserves both and never renders them; now neither does the split, static or bound.
- A `<select v-model>` rendered no selected option, with a warning. Each `<option>` inside it now has a `:model` slot in its start tag, with the select's value and the option's own: its bound `:value`, its `value` attribute, or its text, so a renderer can mark the selected one, as Vue's server renderer does. The slot's new `:option_value` holds a bound `:value`.

## 0.17.1 - 2026-10-04

### Changed
Expand Down
1 change: 1 addition & 0 deletions codegen/vize/codegen/native_types.ex
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ defmodule Vize.Codegen.NativeTypes do
required(:tag) => String.t(),
required(:type) => String.t() | nil,
required(:static_value) => String.t() | nil,
required(:option_value) => String.t() | nil,
required(:value) => String.t(),
required(:position) => {R.usize(), R.usize()}
}
Expand Down
6 changes: 5 additions & 1 deletion lib/vize.ex
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,11 @@ defmodule Vize do
beside `:class="b"`, and `:show` for the `v-show` a `style` combines with
* `:spread` — the attributes of an object, from `v-bind="attrs"` (`:value`)
* `:model` — what a `v-model` renders on an `<input>` or `<textarea>`:
`:value`, the element's `:tag`, and its static `:type` and `:static_value`
`:value`, the element's `:tag`, and its static `:type` and
`:static_value`. Each `<option>` inside a `<select v-model>` has one
in its start tag, with the select's `:value`, `:tag` `"option"`, and the
option's own value: its bound `:option_value`, or else its
`:static_value`, from its `value` attribute or else its text
* `:if` — `:branches`, each a `:condition` (nil for `v-else`) and a `:block`
* `:for` — `:source`, `:value`, `:key`, `:index`, the repeated element's
`:key_prop`, and its `:block`
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule Vize.MixProject do
use Mix.Project

@version "0.17.1"
@version "0.17.2"
@source_url "https://github.com/elixir-volt/vize_ex"

def project do
Expand Down
1 change: 1 addition & 0 deletions native/vize_ex_nif/src/generated_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ pub struct EncodedModelSlot {
pub tag: String,
pub r#type: Option<String>,
pub static_value: Option<String>,
pub option_value: Option<String>,
pub value: String,
pub position: (usize, usize),
}
Expand Down
85 changes: 78 additions & 7 deletions native/vize_ex_nif/src/template_split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ pub(crate) fn split_template<'a>(
.iter()
.filter_map(|diagnostic| lowering_diagnostic(diagnostic, source))
.collect(),
select: None,
};

let mut block = Block::default();
Expand Down Expand Up @@ -155,6 +156,8 @@ struct Splitter<'s, 'a> {
/// Merged text runs such as `Hello {{ name }}!`, by where they start.
texts: HashMap<u32, &'s [TextPart]>,
diagnostics: Vec<EncodedSplitDiagnostic>,
/// The `v-model` of the `<select>` being split, which selects its options.
select: Option<String>,
}

impl<'s, 'a> Splitter<'s, 'a> {
Expand Down Expand Up @@ -254,6 +257,7 @@ impl<'s, 'a> Splitter<'s, 'a> {
}

let mut content = None;
let mut select = None;

for binding in element.bindings.iter() {
match binding {
Expand Down Expand Up @@ -281,13 +285,15 @@ impl<'s, 'a> Splitter<'s, 'a> {
position: self.position(model.span.start),
});

let slot = self.model_slot(element, model);
if element.tag.eq_ignore_ascii_case("textarea") {
content = Some(slot);
content = Some(self.model_slot(element, model));
} else if element.tag.eq_ignore_ascii_case("input") {
let slot = self.model_slot(element, model);
block.slot(slot);
} else if element.tag.eq_ignore_ascii_case("select") {
select = Some(self.expression(model.contract.read));
} else {
self.warning(model.span.start, model.span.end, "v-model only renders its value on <input> and <textarea> on the server");
self.warning(model.span.start, model.span.end, "v-model only renders its value on <input>, <select> and <textarea> on the server");
}
}
BindingOp::VueHtml(html) => {
Expand Down Expand Up @@ -336,12 +342,22 @@ impl<'s, 'a> Splitter<'s, 'a> {
}
}

if element.tag.eq_ignore_ascii_case("option") {
if let Some(select) = self.select.clone() {
let slot = self.option_slot(element, select);
block.slot(slot);
}
}

block.push(">");

if is_void_tag(element.tag) {
return;
}

// A `<select v-model>` selects the options inside it, in groups too.
let enclosing = select.map(|select| self.select.replace(select));

match content {
Some(slot) => block.slot(slot),
None => {
Expand All @@ -351,6 +367,10 @@ impl<'s, 'a> Splitter<'s, 'a> {
}
}

if let Some(previous) = enclosing {
self.select = previous;
}

block.push("</");
block.push(element.tag);
block.push(">");
Expand All @@ -371,7 +391,7 @@ impl<'s, 'a> Splitter<'s, 'a> {
for attribute in element.attributes.iter() {
let merged =
bound.contains(&attribute.name) || (attribute.name == "style" && show.is_some());
if !merged {
if !merged && !reserved(attribute.name) {
block.push(&static_attribute(attribute));
}
}
Expand All @@ -382,7 +402,7 @@ impl<'s, 'a> Splitter<'s, 'a> {
};

let slot = match bind.name {
Some(DynamicName::Static("key")) => continue,
Some(DynamicName::Static(name)) if reserved(name) => continue,
Some(DynamicName::Static(name)) => self.attr_slot(
Some(name),
None,
Expand Down Expand Up @@ -463,13 +483,15 @@ impl<'s, 'a> Splitter<'s, 'a> {

let statics = attributes
.iter()
.filter(|attribute| !bound.contains(&attribute.name))
.filter(|attribute| !bound.contains(&attribute.name) && !reserved(attribute.name))
.map(|attribute| (attribute.span.start, static_prop(attribute)));

let binds: Vec<_> = bindings
.iter()
.filter_map(|binding| match binding {
BindingOp::Bind(bind) if !matches!(bind.name, Some(DynamicName::Static("key"))) => {
BindingOp::Bind(bind)
if !matches!(bind.name, Some(DynamicName::Static(name)) if reserved(name)) =>
{
Some(bind)
}
_ => None,
Expand Down Expand Up @@ -505,12 +527,42 @@ impl<'s, 'a> Splitter<'s, 'a> {
tag: element.tag.to_string(),
r#type: static_value(&element.attributes, "type"),
static_value: static_value(&element.attributes, "value"),
option_value: None,
value: self.expression(model.contract.read),
position: self.position(model.span.start),
}
.encode(self.env)
}

/// An `<option>` inside a `<select v-model>`: selected when its value,
/// its bound `:value`, its static `value`, or else its text, is the
/// select's.
fn option_slot(&mut self, element: &ElementOp<'_>, select: String) -> Term<'a> {
let bound = element.bindings.iter().find_map(|binding| match binding {
BindingOp::Bind(bind) if matches!(bind.name, Some(DynamicName::Static("value"))) => {
bind.value
}
_ => None,
});

let static_value = match bound {
Some(_) => None,
None => static_value(&element.attributes, "value")
.or_else(|| static_text(&element.children)),
};

EncodedModelSlot {
kind: atoms::model(),
tag: "option".to_string(),
r#type: None,
static_value,
option_value: bound.map(|value| self.expression(value)),
value: select,
position: self.position(element.span.start),
}
.encode(self.env)
}

fn component(&mut self, component: &ComponentOp<'_>) -> Term<'a> {
let mut props = self.props(&component.attributes, &component.bindings);
let mut events = Vec::new();
Expand Down Expand Up @@ -837,6 +889,25 @@ fn static_prop(attribute: &Attribute<'_>) -> EncodedProp {
}
}

/// Attributes Vue reserves for itself, which never render: a template ref
/// and a key.
fn reserved(name: &str) -> bool {
matches!(name, "ref" | "key")
}

/// An element's text, when its children are only text, as an `<option>`
/// without a `value` is valued by it.
fn static_text(children: &Region<'_>) -> Option<String> {
let mut text = String::new();
for op in children.ops.iter() {
match op {
Op::Text(part) => text.push_str(part.content),
_ => return None,
}
}
Some(text.trim().to_string())
}

fn static_attribute(attribute: &Attribute<'_>) -> String {
match attribute.value {
Some(value) => {
Expand Down
37 changes: 37 additions & 0 deletions test/vize/split_template_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ defmodule Vize.SplitTemplateTest do
test ":key isn't rendered" do
assert split(~S|<p :key="id">x</p>|).slots == []
end

test "ref and key, which Vue reserves, aren't rendered, static or bound" do
result = split(~S|<div><input ref="search" key="k" :ref="el" class="a"></div>|)

assert result.statics == [~S|<div><input class="a"></div>|]
assert result.slots == []
end

test "nor are they a root element's attributes or a component's props" do
assert [%{kind: :root_attrs, props: [%{name: "class", static: "a"}]}] =
split(~S|<input ref="search" key="k" class="a">|, root_attrs: true).slots

assert [%{kind: :component, props: [%{name: "title", static: "x"}]}] =
split(~S|<MyThing ref="t" key="k" title="x" />|).slots
end
end

describe "bindings" do
Expand All @@ -99,6 +114,28 @@ defmodule Vize.SplitTemplateTest do
result.slots
end

test "v-model on a select selects its options, by value, bound value or text" do
result =
split(
~S|<select v-model="c"><option value="a">A</option><optgroup label="g"><option>B</option></optgroup><option v-for="o in opts" :value="o.id">{{ o.name }}</option></select>|
)

assert result.diagnostics == []

assert [
%{kind: :model, tag: "option", value: "c", static_value: "a", option_value: nil},
%{kind: :model, tag: "option", value: "c", static_value: "B", option_value: nil},
%{kind: :for, block: %{slots: [%{kind: :attr, name: "value"}, option | _text]}}
] = result.slots

assert %{kind: :model, tag: "option", static_value: nil, option_value: "o.id"} = option
assert hd(result.statics) == ~S|<select><option value="a"|
end

test "an option outside a select v-model has no slot" do
assert split(~S|<select><option value="a">A</option></select>|).slots == []
end

test "v-model on a textarea renders its content" do
result = split(~S|<textarea v-model="body"></textarea>|)

Expand Down
Loading