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
2 changes: 1 addition & 1 deletion .sync/dep-parity.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$note": "Upstream's pinned versions for every dependency both trees declare in the SAME section, snapshotted at `cursor`. The sync ports deltas, which is correct per commit and lets a one-time divergence become permanent: once a version is off upstream's line, every later `chore(deps)` batch skips it, because those ports bump only where this fork already matched upstream's pre-image. `prettier` sat at ^3.8.4 against upstream's ^3.9.6 for that reason, through four ported batches, until this file was written. Section-aware on purpose: 18 packages are declared on both sides but in different sections — the whole `@tiptap/*` family is a peer `^3` upstream and a dependency `^3.29.2` here, and `ai` is a peer there and a devDependency here. Those are structural divergences, not drift, and comparing a peer range against a dependency range says nothing. They are absent from this file by construction rather than by omission. Guarded by `test/utils/dep-parity.spec.ts`. Refresh with `node .sync/dep-parity.mjs <path-to-nuxt-ui-mirror> [cursor]`, which preserves `exceptions`.",
"cursor": "ebd4adfccc9e983ccd65d8d2bbbbaaa4e8e7d9e6",
"cursor": "2d4782ba926ff911a2a7178372c062a3e7150fff",
"manifests": {
"package.json": {
"dependencies": {
Expand Down
59 changes: 59 additions & 0 deletions .sync/log/2d4782ba926ff911a2a7178372c062a3e7150fff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# port — nuxt/ui@2d4782ba926ff911a2a7178372c062a3e7150fff

**Upstream:** fix(InputNumber): work uncontrolled with only a default value (#6859)

**Decision:** port, verbatim. Third of ten commits in this run.

## The defect

`useVModel(props, 'modelValue', emits, { defaultValue })` kept its **own** copy
of the value and that copy was bound to `NumberFieldRoot`'s `model-value`. With
only a `defaultValue` and no `v-model`, reka has internal state of its own, and
the two disagreed: the field stepped off the wrapper's copy instead of off reka's,
so an uncontrolled `InputNumber` did not increment.

Removing the wrapper alone would trade that for a different bug, which is why
the fix has a second half. Reka writes `update:modelValue` on **every** write —
blur, Enter, stepping at a bound — whether or not the value actually moved. With
`useVModel` gone there is nothing absorbing those, so `onUpdate` gains an
equality guard and emits by hand:

```ts
if (value === props.modelValue || (value == null && props.modelValue == null)) {
return
}

emits('update:modelValue', value as ApplyModifiers<T, Mod>)
```

The second clause is not redundant. `undefined` and `null` are different values
but the same emptiness, and the `.optional` modifier produces the first while a
caller supplies the second.

`useForwardProps(…, emits)` loses its second argument for the same reason: the
root must not re-emit what `onUpdate` now owns.

## b24ui port

Applies verbatim. Our pre-image is upstream's at all five sites, `eslint-disable`
comment included, and `useVModel` appears nowhere else in the file. Selectors and
imports the new tests need — `data-slot="increment"`, `flushPromises`, `test` —
were already present.

## Tests

Upstream's four, taken as written. Verified against the pre-image rather than
assumed: with the component reverted and the tests kept, **all four fail**; with
the port, all pass. So this fixes a defect that was live in this fork, not a
theoretical one.

Mutation-checked, and the counts separate the two halves of the fix:

| mutation | result |
| --- | --- |
| drop the `emits('update:modelValue', …)` call | **14 red** |
| drop the equality guard entirely | **6 red** |
| keep only `value === props.modelValue`, drop the `== null` clause | **2 red** |

The last row is the one worth having: it shows the `== null` clause is
load-bearing and covered, rather than defensive padding that no test would miss.
10 changes: 8 additions & 2 deletions .sync/nuxt-ui.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"upstream": "nuxt/ui",
"branch": "v4",
"cursor": "ebd4adfccc9e983ccd65d8d2bbbbaaa4e8e7d9e6",
"cursor": "2d4782ba926ff911a2a7178372c062a3e7150fff",
"_cursor_note": "cursor = last upstream commit ported into b24ui. The sync is manual by decision: one commit at a time, oldest-first, each with a `.sync/log/<sha>.md` journal and an entry in `processed`. There is no dispatcher, no porter workflow and no kill-switch — `.sync/PORTING.md` is the whole procedure. `processed` is maintained per port (backfilled #68-#72 on 2026-06-09).",
"processed": {
"2799fa6f2b25ce3eb15e050f3ef7c57d0d9a2fdb": {
Expand Down Expand Up @@ -1777,10 +1777,16 @@
"summary": "fix(Table): exclude hidden columns from colspan (nuxt/ui #6894) — PORT, verbatim. First of ten commits in this run, taken alone; the next is a chore(deps) batch with nothing in common. Five colspan expressions counted ALL columns while the body rendered only the visible ones: row.getAllCells() -> getVisibleCells() for the expanded row, and tableApi.getAllLeafColumns() -> getVisibleLeafColumns() for empty, loading and the two virtualizer padding rows. Hide a column and every full-width cell was one too wide, so the <td> overhung the row it was meant to span. Applies verbatim: our pre-image has the same five sites and the same two method names, and Table.vue:569 already rendered row.getVisibleCells() — the row body and the colspan were reading different column sets, which is the whole defect. Coverage measured PER SITE rather than assumed, and the measurement changed what got written: reverting expanded alone turns 2 red and empty alone turns 2 red, but loading alone leaves 78 green and the two virtualizer rows alone leave 78 green — so three of the five were riding on a test that cannot see them. Added one assertion for loading, which reads the count from the same object as empty but through a different branch, so a fix applied to one and not the other would still have passed; it now turns 2 red when reverted alone. The two virtualizer padding rows stay UNCOVERED and this is stated rather than glossed: they are aria-hidden spacers existing only when the virtualizer is active, so reaching them means standing up a virtualizer harness, out of proportion to a one-word change, and upstream has no such test either. The added assertion nearly went in wrong — it first passed ...props, which carries data, but the loading row is a sibling v-else-if of the rows and renders only when there are none, so the rows mounted and the query came back empty; it failed on correct code, which is the good failure mode."
},
"ebd4adfccc9e983ccd65d8d2bbbbaaa4e8e7d9e6": {
"pr": 551,
"b24ui_sha": "d57db68231238c2a2e06e2e96eea1dcf3be622dc",
"decision": "port",
"summary": "chore(deps): update all non-major dependencies (nuxt/ui #6925) — PORT, minus one package, plus one fork-only companion change. Second of ten commits in this run. Every shared package sat at upstream's exact pre-image, checked package by package rather than inferred from dep-parity being green: root takes @internationalized/date ^3.12.4, @internationalized/number ^3.6.8, motion-v ^2.4.2, ai ^7.0.93, eslint ^10.10.0; docs takes @ai-sdk/vue ^4.0.93, ai, joi ^18.2.8, maska ^3.2.1, motion-v; playgrounds/nuxt takes @ai-sdk/vue, @internationalized/date, ai; playgrounds/vue takes vue-router ^5.3.1. Six upstream names are absent here and the @internationalized/* PEER ranges are ^3.0.0 on both sides, untouched by upstream either. Two upstream changes n/a: the zod entry in playgrounds/nuxt/nuxt.config.ts paths (no typescript block here at all) and the ContentToc snapshot, which moves only under happy-dom 20.14 — a version we do not take. playgrounds/demo is ours alone and declares four of the bumped packages: two at upstream's pre-image take the bump mechanically, and TWO HAD DRIFTED BEHIND OUR OWN ROOT (@ai-sdk/vue ^4.0.77 vs our ^4.0.85, ai ^7.0.77 vs ^7.0.85), brought up with the rest — exactly the 'one-time divergence becomes permanent' failure dep-parity's note describes, and demo is invisible to that file because upstream has no counterpart. maska and @internationalized/date in the nuxt and vue playgrounds stay put: upstream does not declare them there, so there is no pre-image to move from — structural, not drift. THE ONE PACKAGE NOT TAKEN IS happy-dom. Upstream goes ^20.12.0 -> ^20.14.0; taking it turns 1450 tests and 136 snapshots red across 64 files with TypeError: 'defineProperty' on proxy: trap returned falsish for property '__v_skip' — Vue's markRaw against a happy-dom proxy, raised from reka's Presence watcher. Isolated rather than guessed (every other bump applied, happy-dom alone moved back => green) then bisected: 20.12.0 passes, 20.12.1 / 20.12.2 / 20.13.0 / 20.13.1 / 20.14.0 all fail. The finding is LARGER than the port: the declared range was ^20.12.0, which admits every broken version, and it worked only because the lockfile happened to hold 20.12.0 — any fresh resolution or Dependabot bump would have reddened the suite with no source change to point at, and ~20.12.0 is not enough either since 20.12.1 already breaks. So the declaration becomes an exact pin, 20.12.0, with the reason in dep-parity.json exceptions where the next chore(deps) port will read it. That closes a landmine rather than adding a restriction. Upstream ships ^20.14.0 and is presumably green, so whatever differs is on our side — reka's version or the vitest environment — and finding it is not something a dependency-bump port can do; recorded for a follow-up. Verified: lint and typecheck green, test 7857 passed / 6 skipped / 0 failed, resolved versions read from pnpm-lock.yaml rather than node_modules."
},
"2d4782ba926ff911a2a7178372c062a3e7150fff": {
"pr": "pending-merge",
"b24ui_sha": "pending-merge",
"decision": "port",
"summary": "chore(deps): update all non-major dependencies (nuxt/ui #6925) — PORT, minus one package, plus one fork-only companion change. Second of ten commits in this run. Every shared package sat at upstream's exact pre-image, checked package by package rather than inferred from dep-parity being green: root takes @internationalized/date ^3.12.4, @internationalized/number ^3.6.8, motion-v ^2.4.2, ai ^7.0.93, eslint ^10.10.0; docs takes @ai-sdk/vue ^4.0.93, ai, joi ^18.2.8, maska ^3.2.1, motion-v; playgrounds/nuxt takes @ai-sdk/vue, @internationalized/date, ai; playgrounds/vue takes vue-router ^5.3.1. Six upstream names are absent here and the @internationalized/* PEER ranges are ^3.0.0 on both sides, untouched by upstream either. Two upstream changes n/a: the zod entry in playgrounds/nuxt/nuxt.config.ts paths (no typescript block here at all) and the ContentToc snapshot, which moves only under happy-dom 20.14 — a version we do not take. playgrounds/demo is ours alone and declares four of the bumped packages: two at upstream's pre-image take the bump mechanically, and TWO HAD DRIFTED BEHIND OUR OWN ROOT (@ai-sdk/vue ^4.0.77 vs our ^4.0.85, ai ^7.0.77 vs ^7.0.85), brought up with the rest — exactly the 'one-time divergence becomes permanent' failure dep-parity's note describes, and demo is invisible to that file because upstream has no counterpart. maska and @internationalized/date in the nuxt and vue playgrounds stay put: upstream does not declare them there, so there is no pre-image to move from — structural, not drift. THE ONE PACKAGE NOT TAKEN IS happy-dom. Upstream goes ^20.12.0 -> ^20.14.0; taking it turns 1450 tests and 136 snapshots red across 64 files with TypeError: 'defineProperty' on proxy: trap returned falsish for property '__v_skip' — Vue's markRaw against a happy-dom proxy, raised from reka's Presence watcher. Isolated rather than guessed (every other bump applied, happy-dom alone moved back => green) then bisected: 20.12.0 passes, 20.12.1 / 20.12.2 / 20.13.0 / 20.13.1 / 20.14.0 all fail. The finding is LARGER than the port: the declared range was ^20.12.0, which admits every broken version, and it worked only because the lockfile happened to hold 20.12.0 — any fresh resolution or Dependabot bump would have reddened the suite with no source change to point at, and ~20.12.0 is not enough either since 20.12.1 already breaks. So the declaration becomes an exact pin, 20.12.0, with the reason in dep-parity.json exceptions where the next chore(deps) port will read it. That closes a landmine rather than adding a restriction. Upstream ships ^20.14.0 and is presumably green, so whatever differs is on our side — reka's version or the vitest environment — and finding it is not something a dependency-bump port can do; recorded for a follow-up. Verified: lint and typecheck green, test 7857 passed / 6 skipped / 0 failed, resolved versions read from pnpm-lock.yaml rather than node_modules."
"summary": "fix(InputNumber): work uncontrolled with only a default value (nuxt/ui #6859) — PORT, verbatim. Third of ten commits in this run. The defect: useVModel(props,'modelValue',emits,{defaultValue}) kept its OWN copy of the value and that copy was bound to NumberFieldRoot's model-value, so with only a defaultValue and no v-model the field stepped off the wrapper's copy instead of off reka's internal state and did not increment at all. Removing the wrapper alone trades that for a different bug, which is why the fix has a second half: reka writes update:modelValue on EVERY write — blur, Enter, stepping at a bound — whether or not the value moved, and with useVModel gone nothing absorbs those, so onUpdate gains an equality guard and emits by hand. The guard's second clause is not redundant: undefined and null are different values but the same emptiness, and the .optional modifier produces the first while a caller supplies the second. useForwardProps loses its emits argument for the same reason — the root must not re-emit what onUpdate now owns. Applies verbatim: our pre-image is upstream's at all five sites, eslint-disable comment included, useVModel appears nowhere else in the file, and the selectors and imports the new tests need (data-slot=increment, flushPromises, test) were already present. Upstream's four tests taken as written and VERIFIED AGAINST THE PRE-IMAGE rather than assumed: with the component reverted and the tests kept, all four fail; with the port, all pass — so this fixes a defect that was live in this fork, not a theoretical one. Mutation-checked, and the counts separate the two halves of the fix: dropping the emits('update:modelValue') call turns 14 red, dropping the equality guard entirely turns 6 red, and keeping only value === props.modelValue while dropping the == null clause turns 2 red. That last row is the one worth having — it shows the == null clause is load-bearing and covered rather than defensive padding no test would miss."
}
}
}
18 changes: 11 additions & 7 deletions src/runtime/components/InputNumber.vue
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export interface InputNumberSlots {
<script setup lang="ts" generic="T extends InputNumberValue = InputNumberValue, Mod extends Pick<ModelModifiers, 'optional'> = Pick<ModelModifiers, 'optional'>">
import { onMounted, computed, useTemplateRef, toRef } from 'vue'
import { NumberFieldRoot, NumberFieldInput, NumberFieldDecrement, NumberFieldIncrement } from 'reka-ui'
import { reactivePick, useVModel } from '@vueuse/core'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useComponentProps } from '../composables/useComponentProps'
import { useForwardProps } from '../composables/useForwardProps'
Expand Down Expand Up @@ -136,13 +136,10 @@ defineSlots<InputNumberSlots>()

const props = useComponentProps<InputNumberProps<T, Mod>>('inputNumber', _props)

// eslint-disable-next-line vue/no-dupe-keys
const modelValue = useVModel<InputNumberProps<T, Mod>, 'modelValue', 'update:modelValue'>(props, 'modelValue', emits, { defaultValue: props.defaultValue })

const { t } = useLocale()
const appConfig = useAppConfig() as InputNumber['AppConfig']

const rootProps = useForwardProps(reactivePick(props, 'as', 'stepSnapping', 'formatOptions', 'disableWheelChange', 'invertWheelChange', 'required', 'readonly', 'focusOnChange', 'locale'), emits)
const rootProps = useForwardProps(reactivePick(props, 'as', 'stepSnapping', 'formatOptions', 'disableWheelChange', 'invertWheelChange', 'required', 'readonly', 'focusOnChange', 'locale'))

const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color: formFieldColor, size: formFieldSize, name, highlight: formFieldHighlight, disabled: formFieldDisabled, ariaAttrs } = useFormField<InputNumberProps<T, Mod>>(_props)
const { orientation, size: fieldGroupSize } = useFieldGroup<InputNumberProps<T, Mod>>(_props)
Expand Down Expand Up @@ -184,9 +181,16 @@ const inputRef = useTemplateRef('inputRef')

function onUpdate(value: ApplyModifiers<T, Mod> | undefined) {
if (props.modelModifiers?.optional) {
modelValue.value = value = value ?? undefined
value = value ?? undefined
}

// In controlled mode reka emits on every write, even when nothing changed (blur, Enter, stepping at a bound).
if (value === props.modelValue || (value == null && props.modelValue == null)) {
return
}

emits('update:modelValue', value as ApplyModifiers<T, Mod>)

// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
emits('change', event)
Expand Down Expand Up @@ -222,7 +226,7 @@ defineExpose({
v-bind="rootProps"
:id="id"
:default-value="props.defaultValue"
:model-value="modelValue"
:model-value="props.modelValue"
:min="props.min"
:max="props.max"
:step="props.step"
Expand Down
Loading