Skip to content

NumberField: typed value never commits via parent v-model — commit() clobbers the parse with a stale model read #329

@johnleider

Description

@johnleider

Environment

packages/0 master (worktree at feat/builder, identical NumberField source).

Repro

  1. Bind a NumberField to parent state: <NumberField.Root v-model="state.breakpoints[name]"> (e.g. the builder app's Breakpoints form, apps/builder/src/plugins/breakpoints/BreakpointsConfig.vue).
  2. Focus the control, type a new value (e.g. 1000), then blur or press Enter.
  3. The field snaps back to the previous value; the parent model never receives the typed value.

ArrowUp/ArrowDown stepping and programmatic writes propagate fine — only the typed parse→commit path is broken, and only when the model is parent-bound (plain local refs take defineModel's local-value path and don't reproduce, which is why unit tests pass).

Mechanism

  1. NumberFieldControl.vue onInput updates only text.value, not the model.
  2. On blur/Enter (NumberFieldControl.vue:102-108, :128-132): root.value.value = parsed then root.commit().
  3. root.value is the defineModel ref (NumberFieldRoot.vue:227). With a parent listener, the write emits update:modelValue, but the child's prop hasn't round-tripped yet — a synchronous re-read returns the stale value.
  4. commit() (createNumberField/index.ts:208-219) re-reads value.value synchronously and re-assigns value.value = numeric.snap(value.value) — re-emitting the old number and clobbering the parse in the same tick. syncText() then restores the old text.

The existing tests document the flake instead of catching it: packages/0/src/components/NumberField/index.test.ts:1568 and :1588 accept either the new or the old value.

Proposed fix

Thread the parsed value through instead of re-reading the model:

// NumberFieldControl.vue (blur + Enter)
const parsed = root.parse(text.value)
root.value.value = parsed
root.commit(parsed)

// createNumberField/index.ts
function commit (next?: number | null): void {
  const val = next ?? value.value
  if (isNull(val)) return
  // ...range/snap logic on `val`...
  value.value = numeric.snap(val)
}

Regression test: mount with a parent-bound v-model, type + blur, assert the parent ref received the typed value (and tighten the two either/or assertions at index.test.ts:1568/1588).

Worth grepping sibling composables (createSlider, createInput, createRating) for the same write-then-synchronously-re-read-model shape while fixing.

Impact

Any consumer binding NumberField through parent state cannot type values — discovered because custom breakpoint px values are impossible to enter in the framework builder.

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions