Skip to content

feat(components): two-way binding for Input (v-model / updateModelValue)#461

Merged
alexgrozav merged 12 commits into
mainfrom
alexgrozav/uxf-5-input-two-way-binding-v-model-updatemodelvalue
Jun 10, 2026
Merged

feat(components): two-way binding for Input (v-model / updateModelValue)#461
alexgrozav merged 12 commits into
mainfrom
alexgrozav/uxf-5-input-two-way-binding-v-model-updatemodelvalue

Conversation

@alexgrozav

@alexgrozav alexgrozav commented Jun 10, 2026

Copy link
Copy Markdown
Member

Summary

Adds two-way binding ($bind:) support to the compiler and wires it through the Input component, so a single authored model emits idiomatic two-way bindings per target — Vue v-model, Svelte bind:, React/Solid/Qwik value + onUpdate/event pairs. Bundles several compiler codegen fixes surfaced while implementing it (Svelte {@render} snippets and aliased-model bindings, Vue multi-child slot fragments, Qwik model/event-only props, zero-arg recipe call syntax, component event-name casing, INK0045 reclassified to info, and a redundant plugin barrel export removed).

Issue

https://linear.app/grozav/issue/UXF-5/input-two-way-binding-v-model-updatemodelvalue

Test plan

  • vp run ready passes locally
  • New two-way-binding lower-pass and CLI compile tests pass; Input headless snapshots updated

Checklist

  • Added changesets for each published-package change
  • Updated core/core/AGENTS.md and docs/authoring-components.md for the new authoring API
  • Commit messages follow the conventional format

alexgrozav added 12 commits June 8, 2026 10:02
Solid/Svelte emit component callback props via an isComponent-aware rewriteEventName (native DOM listeners stay lowercase); Angular lowercases only the leading char of an @output() name. Prerequisite for component custom-event emission (UXF-5).
…rse/IR/lower)

New @inkline/core primitives defineModel/defineEmits. Compiler parses them into IRComponent.models + synthesized value prop + update:<name> model event + emitName, and lowers $bind:<prop> on component instances (getter convention, twoWayProp marker). IR_VERSION 1->2 + migration. Diagnostics INK0043/INK0044. Codegen for these lands in following phases.
… side)

Shared modelReads/modelSetters/emit rewrite rules + eventToCallbackProp/callbackPropRules. Per target: Vue defineModel/defineEmits, Svelte $bindable + callback props, Angular model()/output(), React/Solid value prop + onUpdate callback, Qwik QRL callbacks, Astro read-only let + no-op (INK0045). component.models is the single source (no synthesized prop/event). Model/callback names excluded from attribute fallthrough.
Each target renders the lowered update:<prop> event idiomatically via the twoWayProp marker: React/Solid onUpdate<Prop> callback, Qwik onUpdate<Prop>$ QRL, Vue v-model:<prop>, Svelte bind:<prop>, Angular [value]+( <prop>Change), Astro one-way (drops the inert update). Also: Svelte child models use $bindable only (dropped the spurious onUpdateValue callback). New same-module BoundField + native NativeBind fixtures + per-target parent assertions + scenarios.
Migrate IInputControlBase to defineModel("value") wired into both <Show> branches; IInputControl forwards it via $bind:value. Two compiler fixes the migration required: two-way-binding lowering now resolves $bind: to forwarded models (not just createSignal state), and Qwik keeps prop-less components' params untyped. Adds a two-way Storybook story, README/AGENTS/authoring docs, and a changeset for the 7 framework packages.
…n-void tags

The Svelte target lowered slots to the deprecated `<slot>` element and self-closed every empty element (incl. non-void `<span>`/`<textarea>`), producing slot_element_deprecated and element_invalid_self_closing_tag warnings on build.

Slots now compile to runes: default -> {@render children?.()}, named -> {@render <name>?.()}, fallback wrapped in {#if}/{:else}, scoped slots thread positional args. Each slot is declared as a typed Snippet prop on $props(); a named slot's prop binds to a <name>Snippet local so {@render} can't collide with an in-scope binding (e.g. {#each items as item} vs an `item` slot). Non-void empty elements now print as <tag></tag>. Output is functionally identical.
src/index.ts exported `unplugin` as both a named and the default export, so bundling the barrel warned "named and default exports together". The default was redundant with the named `unplugin` and unused (the per-bundler entries supply the default plugins). The barrel now exports only named symbols, clearing the warning without changing the per-bundler entries' CommonJS interop.
…mplate>

A multi-child Fragment (component default slot, multi-root render, or multi-node slot fallback) was wrapped in a directive-less <template>, which Vue renders as an inert HTML element — dropping every child. Now emits a cGroup (bare siblings), matching Svelte and the JSX targets. Adds the MultiChildSlot fixture + a Vue slots assertion; updates the SlotScoped Vue snapshot (fallback no longer wrapped). Pre-existing bug surfaced by the Input two-way story.
… level

INK0045 (Astro two-way binding / events not interactive) is an advisory about a
target limitation, not an actionable warning — reclassify it from `warning` to
`info`, matching the other advisory codes (INK0061, INK0066).

Add `meetsLevel(severity, level)` to gate diagnostics by a minimum severity. The
CLI `inkline compile --watch` loop now reports `warning` and above, so the
INK0045 notice no longer spams the dev loop on every recompile while genuine
warnings (e.g. missing key) still surface. One-shot `inkline compile` and
`inkline check` keep the `info` floor and print everything.
A zero-arg call such as a styleframe recipe `inputAppendRecipe()` was treated as a reactive signal read and rewritten per target — stripped to a bare `inputAppendRecipe` (React/Svelte/Vue/Astro) or `inputAppendRecipe.value` (Qwik) — so the recipe *function*, not its result, ended up in the class attribute.

The expression rewriter now applies a target's reactive-read convention only to identifiers that are actually reactive accessors (signals, memos, model getters), collected from the component IR via `reactiveReadNames()` and threaded in as `rules.reactiveReads`. Every other zero-arg call (an imported recipe, a plain helper) keeps its call syntax across all 7 targets. Also fixes resource fetchers whose call was likewise stripped.

Adds a RecipeNoArgs fixture + per-target regression tests and unit gating tests.
…props param

Svelte declared each model's $bindable() binding under the public prop name
while reads/writes resolve to the getter local, so an aliased model
(defineModel("open") bound to a local isOpen) referenced an undeclared
variable in both the template and the reconstructed whole-props object. The
binding now renames when the names differ (open: isOpen = $bindable()).

Qwik only emitted the props parameter for components with plain props, slots,
or attribute fallthrough, yet models/events compile to props.<x> references.
A model- or event-only component with a non-fallthrough root (e.g. a Fragment)
emitted component$(() => while referencing props.value. Models and events are
now included in the parameter condition.
@codecov

codecov Bot commented Jun 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.72678% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 93.93%. Comparing base (55c31d3) to head (0509378).

Files with missing lines Patch % Lines
...er/src/pipeline/passes/03-lower/two-way-binding.ts 97.72% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #461      +/-   ##
==========================================
+ Coverage   93.51%   93.93%   +0.41%     
==========================================
  Files         108      109       +1     
  Lines        4890     5160     +270     
  Branches     1687     1786      +99     
==========================================
+ Hits         4573     4847     +274     
+ Misses        269      265       -4     
  Partials       48       48              
Flag Coverage Δ
shard-1 85.18% <96.67%> (+0.39%) ⬆️
shard-2 89.46% <95.53%> (+0.38%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@alexgrozav alexgrozav merged commit c12188d into main Jun 10, 2026
10 checks passed
@alexgrozav alexgrozav deleted the alexgrozav/uxf-5-input-two-way-binding-v-model-updatemodelvalue branch June 10, 2026 07:02
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