ReviewOS

stacks/stx

x-model writes el.value unconditionally: caret jumps to the end when editing mid-text

#1799
Closed glennmichael123 opened this 11 days ago ·
11 days ago

Verified against @stacksjs/stx@0.2.152, dist/signals.js.

bindModel's text branch writes the element's value on every signal change, with no check that the value actually differs:

effect(() => { el.value = getValue() ?? ''; });
el.addEventListener(modifiers.includes('lazy') ? 'change' : 'input', () => setValue(coerceValue(el.value)));

That forms a loop on every keystroke: input → setValue → the signal changes → the effect re-runs → el.value = …. Assigning to .value moves the text entry cursor to the end of the control, so the caret is reset on each character.

grep -c 'selectionStart\|setSelectionRange' dist/signals.js returns 0 — there is no caret preservation anywhere in the runtime either.

Why this has probably gone unnoticed

It is invisible in the common case. When you are typing at the END of the field, "caret jumps to the end" is a no-op. The bug only appears when the user puts the cursor in the middle of existing text and types — every character sends the caret back to the end.

So a login form with x-model="email" feels perfectly fine, and a textarea people actually edit does not.

Why it matters more inside :for

bindFor does key-based diffing and reuses nodes, updating the item signal on reuse (signals.item.set(list[i]), the #1669 fix). That is the right behaviour — but it means every keystroke re-evaluates the reused row's bindings, so an x-model textarea inside a list re-runs the unguarded write on each character.

This is currently a blocker for migrating an imperative editor to declarative rendering in my app. The code being replaced goes out of its way to never write back to the textarea while typing, with a comment explaining that rebuilding the row would "steal focus" — so adopting x-model there would be a regression, not an improvement.

Suggested fix

The same guard Vue and Alpine both carry:

effect(() => {
  const next = getValue() ?? ''
  if (el.value !== next) el.value = next
})

That alone fixes the typing case, because the value the effect computes is the value the user just typed.

For the rarer case where the value legitimately changes from elsewhere while the field is focused (a formatter, a remote sync), preserving the offset is a further improvement:

effect(() => {
  const next = getValue() ?? ''
  if (el.value === next) return
  const active = document.activeElement === el
  const start = active ? el.selectionStart : null
  el.value = next
  if (active && start !== null && el.setSelectionRange) {
    const pos = Math.min(start, next.length)
    el.setSelectionRange(pos, pos)
  }
})

Note the same unconditional write exists in the select branch (syncSelect = () => { el.value = getValue() ?? ''; }), which is harmless there but inconsistent.

I have verified the code path and the absence of any guard; I have not attached a live browser repro. Happy to send a PR with the first form plus a test if that shape looks right.

Sign in to comment on this issue.