Browse docs
Browse docs
Released 2026-05-17 · Hardening patch
Three runtime fixes on form controls plus one accessibility enhancement
on <Button>. All four issues surfaced while building the live-preview
demos for this docs site — the demo registry now serves as a regression
catch for the next release.
Same root cause across the three form-control fixes:
"controlled-without-an-owner" — each component sat in controlled mode
in standalone-uncontrolled scenarios (no DashFormProvider, no
explicit value / checked prop, only defaultValue /
defaultChecked), with no setter to update the controlled prop on
user input. React would snap the input right back. The fixes differ
in implementation shape (Radix indicator → drop forceMount; Radix
root → discriminated spread of value vs defaultValue; native
<input> → local useState) but the diagnosis is identical.
No public API change — 0.2.0-beta → 0.2.1-beta is a drop-in
upgrade. The <Button> enhancement adds a new auto-emitted attribute
(aria-busy={true} while loading) but no new prop.
| Package | What changed |
|---|---|
@dashforge/tw | 4 fixes (3 form-control runtime, 1 Button a11y). 52 component tests across 4 files re-validate. A11Y.md added at package root with the full WAI-ARIA APG mapping. |
Unchanged (independent versioning):
| Package | Version (unchanged) | Why |
|---|---|---|
@dashforge/tw-theme | 0.1.0-beta | No source change — peer dep stays at ^0.1.0-beta. |
@dashforge/tw-tokens | 0.1.0-beta | No source change — peer dep stays at ^0.1.0-beta. |
Bridge (forms, rbac, ui-core) | 0.2.3-beta | Shared with MUI side; no source change. |
MUI side (ui, theme-mui, theme-core, tokens) | 0.2.3-beta | Separate ecosystem; untouched. |
Symptoms: a <Checkbox defaultChecked={false}> mounted outside any
DashFormProvider turned blue on click (the
data-[state=checked]:bg-primary-500 rule fired correctly) but the
white check glyph never appeared inside.
Root cause: the indicator was rendered with forceMount so the wrapper
<span> was always in the DOM, but its content was gated by a React
conditional {resolvedChecked === true ? <CheckIcon /> : null}.
resolvedChecked is a snapshot computed at render time from
checked ?? defaultChecked ?? false. In standalone uncontrolled mode
nobody owned the React state to flip it, so the conditional stayed
false forever after the first render — even though Radix internally
flipped its data-state="checked".
Fix: dropped forceMount and the conditional. The Radix Indicator
now mounts / unmounts based on its own data-state — which IS the
source of truth in all three modes (controlled, uncontrolled, bridge).
- <RadixCheckbox.Indicator className={…} forceMount>
- {resolvedChecked === true ? <CheckIcon className="h-full w-full" /> : null}
- </RadixCheckbox.Indicator>
+ <RadixCheckbox.Indicator className={…}>
+ <CheckIcon className="h-full w-full" />
+ </RadixCheckbox.Indicator>Trade-off: indeterminate state now renders the check glyph too
(previously rendered nothing inside the blue square — same level of
broken). A proper dash icon for indeterminate is a separate concern.
14/14 Checkbox tests still pass.
Symptoms: a <RadioGroup defaultValue="pro"> mounted standalone
appeared to ignore clicks on other options — the selection visibly
moved to the clicked option for one frame, then reverted to "pro".
Root cause: <RadixRadioGroup.Root> was always passed
value={resolvedValue}, putting Radix in controlled mode against a
snapshot that never updated. Radix would fire onValueChange on click,
the handler would forward it to the user's optional onValueChange
callback (no state setter), and then Radix reverted because the
controlled value prop hadn't changed.
Fix: discriminated spread of the props passed to RadixRadioGroup.Root:
- <RadixRadioGroup.Root value={resolvedValue} onValueChange={…} … />
+ <RadixRadioGroup.Root
+ {...(isFormMode || explicitValue !== undefined
+ ? { value: resolvedValue }
+ : { defaultValue: defaultValue ?? undefined })}
+ onValueChange={…}
+ …
+ />Standalone-with-only-defaultValue now passes defaultValue so Radix
owns its own state.
11/11 RadioGroup tests still pass.
Symptoms: typing in <NumberField defaultValue={1} /> (or clicking the
+/− stepper) appeared to do nothing — the input snapped back to 1
on every keystroke / click.
Root cause: same family — value={resolvedDisplayValue} always passed
to the native <input type="number">, where resolvedDisplayValue
was formatForDisplay(defaultValue) at render time. handleChange
called writeToBridge which short-circuited when not in form mode,
so the controlled input never saw a new value.
Fix: added local useState<string> (mirroring the pattern OTPField
already uses) seeded from defaultValue. Both handleChange and
stepBy write to it when in standalone uncontrolled mode. Form mode
keeps using the bridge as source of truth; standalone-controlled mode
keeps using the consumer's value.
Note: NumberField is a native <input> — React doesn't offer a "let
the DOM own state" mode like Radix does, so local state is the only
path for this component (unlike Checkbox + RadioGroup above).
8/8 NumberField tests still pass.
aria-busy={loading} now emittedAssistive tech previously couldn't distinguish "Button is disabled
because an async action is in flight" from "Button is disabled because
you don't have permission" / "Button is intrinsically disabled" — all
three produced the same DOM (disabled attribute set, aria-disabled
maybe set via RBAC). Screen readers announced "dimmed" with no signal
about WHY.
Fix: when loading={true}, the button (or asChild Slot target) now
also gets aria-busy="true". WAI-ARIA Implementations of Authoring
Practices distinguish "busy, please wait" from "static disabled", so
screen readers can announce the loading state appropriately (NVDA
says "busy", VoiceOver says "loading"; both are correct).
19/19 Button tests still pass.
VERSION constant exported from @dashforge/tw is now in sync
with package.json (0.2.1-beta). The previous 0.2.0-beta shipped
with VERSION = '0.1.0-beta' due to a missed bump — fixed now.A11Y.md added at package root — full WAI-ARIA Authoring
Practices Guide compliance mapping for every shipped component (24
total). Audit found that 23 of 24 components were already compliant
pre-release; only <Button> needed the aria-busy enhancement
above. Known non-blocking limitations filed: AppShell mobile drawer
focus trap, prefers-reduced-motion pass, color-contrast CI suite,
Lighthouse / axe automated scan.No breaking changes. Drop-in upgrade:
pnpm up @dashforge/tw@^0.2.1-betaIf you were relying on the broken behaviour of any of the three
form controls in standalone uncontrolled mode (somehow consuming the
onCheckedChange / onValueChange / onChange callback without
updating local state), that callback signature is unchanged — but
the field will now correctly track its own state across user
interactions, which it didn't do before. This is the expected /
documented behaviour.
If your test suite explicitly asserts that <button aria-busy> is
ABSENT on a <Button loading>, that assertion will need to flip.