Customer Account UI Extension: `s-number-field` stepper `onInput` behaves like `onChange`

Hi Shopify team,

I found a mismatch between the documented onInput / onChange contract on s-number-field and what happens when controls="stepper" is used.

Environment

  • Surface: Customer Account UI Extension
  • Target: customer-account.order-status.cart-line-item.render-after
  • @shopify/ui-extensions: 2026.4.3
  • Extension api_version: 2026-04
  • Runtime: customer account web extension

What the docs say

s-number-field documents both input and change events.

The Polaris web components event guide says:

  • onInput: fires on every keystroke or value change. Use this for real-time updates.
  • onChange: fires when the value is committed (e.g. blur). Use this after the user finishes.

controls="stepper" is a documented way to change the value incrementally (quantity selectors, etc.). Each stepper click is a value change, so onInput should fire on every increment/decrement.

What I see

Manual typing matches the docs:

  • onInput fires on every keystroke
  • onChange fires later, when the value is committed

Stepper controls do not:

  • Clicking + / - does not fire onInput continuously as the value changes
  • onInput fires once, with the same timing as onChange

So for stepper interactions, onInput is effectively onChange. That breaks live quantity UIs (live totals, min-quantity messaging, enabling a button as the value updates, etc.).

Minimal repro

import { useState } from "preact/hooks";

function Extension() {
  const [liveValue, setLiveValue] = useState("1");
  const [committedValue, setCommittedValue] = useState("1");

  return (
    <s-stack gap="base">
      <s-number-field
        label="Quantity"
        controls="stepper"
        min={1}
        step={1}
        value={liveValue}
        onInput={(event) => {
          const value = event.currentTarget.value;
          console.log("onInput", value);
          setLiveValue(value);
        }}
        onChange={(event) => {
          const value = event.currentTarget.value;
          console.log("onChange", value);
          setCommittedValue(value);
        }}
      />

      <s-text>onInput (live): {liveValue}</s-text>
      <s-text>onChange (committed): {committedValue}</s-text>
    </s-stack>
  );
}

Steps

  1. Type 12 into the field → onInput logs twice (1, then 12); live text updates on each keystroke.
  2. Click stepper + three times (or hold / click repeatedly) → onInput does not fire per click. Both handlers fire once, as if the value were committed.

Expected behavior

Stepper clicks should emit input on every increment/decrement, same as a keystroke.

change should still mean “committed” (blur / finished interaction).

Actual behavior

With controls="stepper", onInput is not continuous. It fires once and matches onChange.

Thanks.

Hi @muchisx, thanks for flagging this and for the minimal reproduction steps.
Looking into this now :slight_smile:

Thank you for taking a look @Paige-Shopify

Hi @muchisx and @Paige-Shopify, thanks for reporting the issue. I was able to reproduce the reported result. I informed the team that owns the behavior, and they are investigating.