Skip to content

feat: built-in on-device controls, on by default; addon-ondevice-controls becomes a stub - #960

Draft
dannyhw wants to merge 7 commits into
nextfrom
dannyhw/feat/builtin-controls
Draft

dannyhw wants to merge 7 commits into
nextfrom
dannyhw/feat/builtin-controls

Conversation

@dannyhw

@dannyhw dannyhw commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

What

The on-device Controls addon is now built into @storybook/react-native and on by default. @storybook/addon-ondevice-controls becomes a no-op compatibility stub.

  • Default on. start() registers the built-in Controls panel unless features.ondeviceControls is false (globalThis.FEATURES?.ondeviceControls !== false).
    • Features.ondeviceControls is documented as "on by default; set false to turn it off".
    • The code lives in packages/react-native/src/controls/, the single source of truth. It's lazily required from start(), right after backgrounds.
  • Stub package. @storybook/addon-ondevice-controls keeps working for projects that still install it and list it in deviceAddons, but they get the panel from the built-in addon.
    • register (and register.js) registers nothing and doesn't touch the built-in panel. In __DEV__ it logs one console.warn saying the addon is now built in, and that you can remove it from deviceAddons and uninstall it.
    • Exports are kept:
      • register, ADDON_ID and PARAM_KEY, re-exported from @storybook/react-native/controls.
      • dist/controlArgTypes (getControlArgTypes, hasEnabledControl), kept because deep imports of it existed, e.g. our own example test.
    • The duplicated source is deleted.
    • package.json drops @gorhom/portal, polished, tinycolor2 and react-native-modal-datetime-picker, and the datetimepicker, slider and bottom-sheet peers. It adds @storybook/react-native (^11.0.0-next.1) as a peer, and the copyimages script is gone. The build still emits dist/index.js for register.js.
    • The README is rewritten as a deprecation and migration note.
  • @storybook/react-native/controls is a new, small subpath export: ADDON_ID, PARAM_KEY, getControlArgTypes, hasEnabledControl and the ArgType/ArgTypes types. It builds to dist/controls/index.js plus a shared chunk, and doesn't pull in the panel UI.
  • Optional native modules. These are unchanged from the earlier version of this PR:
    • @react-native-community/datetimepicker (>=8) and @react-native-community/slider are optional peers.
    • They're loaded by a guarded require written directly in a try block, which is what Metro treats as optional.
    • They're used only when their native side is linked: the RNDateTimePicker view on iOS, the RNCDatePicker/RNCTimePicker modules on Android, and the RNCSlider view.
    • Otherwise the date control falls back to YYYY-MM-DD/HH:mm text inputs and range controls to the JS slider. There's a dev warning when a package is installed but not linked.
    • @gorhom/bottom-sheet stays an optional require. @gorhom/portal, polished and tinycolor2 are regular dependencies. The colour-picker png ships in dist/assets.

Replacing react-native-modal-datetime-picker

It's replaced by DateTimePickerAndroid.open on Android and a small sheet around the native picker on iOS (src/controls/components/DateTimePickerModal.tsx). Why: the package hard-imports the community datetimepicker outside a try block, so keeping it would break bundling for apps without the picker or force a third install.

I compared ours against its v18 source (DateTimePickerModal.ios.js, Modal.js, DateTimePickerModal.android.js) and fixed the gaps I found:

  • It took the value in an effect, so it could show a stale value for a frame.
  • It reset the pending value whenever the arg changed while the sheet was open.
  • When closing, the mode flipped to date while the time sheet faded out.
  • Bottom padding was fixed instead of following the safe area.
  • The buttons had no explicit roles or labels.
  • Nothing stopped Android from opening two dialogs.
Behaviour react-native-modal-datetime-picker v18 Built-in
iOS date and time modes mode prop, display default spinner date and time buttons, display="spinner"
Confirm / Cancel Confirm inside the card, Cancel as a separate card below it Cancel and Confirm side by side in the sheet; only Confirm applies
Backdrop tap Cancels Cancels; hardware back (onRequestClose) also cancels
Animation Backdrop fades to 0.4 and content slides up (JS driver); stays mounted until hidden Same: backdrop fades to 0.4 and the sheet slides (JS driver); the Modal stays mounted until the hide animation ends
Safe area / home indicator Fixed marginBottom: 34 on notched iPhones Bottom padding from SafeAreaInsetsContext (the Storybook UI provides it), with a minimum when there's no provider
Theme System appearance (Appearance.getColorScheme()) Storybook theme: sheet, buttons, themeVariant and textColor from it, so it matches the panel in light and dark
Arg changes while open Ignored; the value is taken only on open (getDerivedStateFromProps) Same: the value is taken during the render that opens the sheet, so the first frame is correct and changes while open don't reset what's picked
Reopening Shows the current value Shows the current value
After Confirm Value applied, no flash Value applied. The mode is kept while closing, so a time sheet doesn't turn into a date picker as it fades
Inside the bottom sheet (full UI) / portal (lite UI) RN Modal RN Modal, tested in both UIs
Android Component-based; mounts <DateTimePicker> per mode and uses memo to avoid double events (#54) Imperative DateTimePickerAndroid.open for date, then time. dismissed is ignored and only set updates the arg. A ref stops a second tap from stacking another dialog, and it's reset by onChange/onError
Accessibility accessibilityRole="button" and labels on Confirm/Cancel Same on Cancel/Confirm. The date and time trigger buttons also get button role, a label ("<arg> date, 25-02-1983") and a hint. accessibilityViewIsModal is set on the sheet

Empty number field after switching stories

Switching stories over the websocket while the Controls panel was open could leave a number input empty (the Range story's second showed blank instead of 7).

This reproduces with the original standalone package on next. ControlsPanel.tsx, hooks.ts, PropForm.tsx, PropField.tsx, types/Number.tsx and useResyncValue.ts were byte-identical to packages/ondevice-controls/src on next when I reproduced it on device.

The cause isn't in Number.tsx:

  • useArgs re-read the new story's args in an effect, so the first render after a story change used the previous story's args.
  • NumberType then mounted with useState(undefined).
  • useResyncValue only resyncs while isPristine, and that was already false from an edit on the previous story.

The fix was small and clearly a bug, so it's in this PR:

  • useArgs reads the new story's args during the render that switches to it (state keyed by storyId).
  • isPristine is tracked per story.
  • PropForm is keyed by storyId.

On device the same scenario now shows 7 straight away.

Examples, docs, migration

  • expo-example: .rnstorybook drops the package from deviceAddons and doesn't set the flag, so this is the built-in-by-default path.
    • .rnstorybook-nofactories still lists @storybook/addon-ondevice-controls, which exercises the stub. The package stays in expo-example's package.json for it.
    • DisabledControls.test.tsx imports from @storybook/react-native/controls.
    • The other examples (expo-router, expo-new-wrapper, repack) still list the package and get the stub.
    • storybook-addon-deep-controls doesn't touch controls internals, and its stories render as before.
  • CLI template (template/cli/main.ts, storybook.requires.ts) no longer installs or registers the package.
  • Docs:
    • configuration/controls.md covers on by default, turning it off, migrating from the package, and the optional native modules.
    • The feature-flags table and the README/readme tables say "on by default; set false to turn it off". There's no "will become default" wording for controls.
    • addons/controls.md and addons/index.md no longer tell you to install the package or its native peers.
    • The manual setup, CLI configuration and backgrounds examples are updated.
    • Also updated: CLAUDE.md, the stories skill and references/controls.md.
  • MIGRATION.md: a new v11 entry, "Controls are built in". It covers removing the package (or leaving it, since the stub is harmless), the datetimepicker and slider now being optional, dropping react-native-modal-datetime-picker, ondeviceControls: false, and the @storybook/react-native/controls import path.
  • Changesets (pre mode on next; breaking changes are recorded as major, like require-device-addons): @storybook/react-native major, and @storybook/addon-ondevice-controls major for the stub.
  • pnpm-lock.yaml: only the importer changes, plus removal of the now-unused react-native-modal-datetime-picker entries. pnpm install --frozen-lockfile passes.

Testing

  • pnpm build, pnpm test, pnpm lint, pnpm format:check and check:types for @storybook/react-native and @storybook/addon-ondevice-controls all pass. Test counts: react-native 105, generator 29, expo-example 32 passed with 2 skipped.
  • New or updated unit tests:
    • builtInControls.test.ts, against the real manager-api:
      • registers by default;
      • doesn't register with ondeviceControls: false;
      • importing the stub and calling register twice, then start(), leaves exactly one Controls panel and one warning.
    • DateTimePickerModal.test.tsx:
      • takes the value on open;
      • an arg change while open keeps the picked value;
      • Confirm applies, Cancel discards;
      • reopening shows the current value and mode;
      • labelled buttons, dark theme props, spinner display.
    • types/Date.test.tsx: the fallback date/time inputs apply a submitted value once, time keeps the date, and invalid input isn't applied.
    • Existing optionalNativeModules.test.ts and dateUtils.test.ts.

iOS 26.4 simulator: expo-example dev build (SDK 57; datetimepicker 9.1.0 and slider 5.2.0 linked)

Built-in by default (.rnstorybook, no package, no flag):

  • Panel: exactly one Controls tab, and no native-module warning.
  • Light mode:
    • The date sheet opens, with buttons above the home indicator.
    • Year changed in the picker, then the arg changed remotely through the channel server: the control showed the new arg while the sheet kept the picked year.
    • A backdrop tap discarded the pick.
    • Reopening showed the current value.
    • Confirm applied 2012 with no stale frame. I checked a screen recording frame by frame: the sheet holds the picked value, then the panel shows the new value.
  • Dark mode: the date and time sheets follow the Storybook dark theme, and the spinner text is readable.
  • Lite UI (EXPO_PUBLIC_LITE_UI=true, dark): the sheet opens over the portal-based panel and Confirm applies.
  • Range story after edits and a story switch: second shows 7 (the empty-field fix).

Stub path (.rnstorybook-nofactories, which still lists @storybook/addon-ondevice-controls):

  • Exactly one Controls tab.
  • Args editable (first → 9, the story shows 9 x 3 = 27).
  • The stub's dev warning was logged exactly once in Metro.

Android: temporary Android 15 emulator, Expo Go SDK 57 (bundles datetimepicker and slider)

  • No native-module warning.
  • The date dialog opens; CANCEL leaves the arg alone and sends no update.
  • Picking the 10th and pressing OK sent exactly one updateStoryArgs. I logged this with a websocket listener on the channel server.
  • The time dialog in text-input mode set 14:00, kept the date, and sent one update.
  • Two rapid taps on the time button opened one dialog; a single CANCEL cleared it, and the button opened it again afterwards.

Android: dev build on an Android 15 (API 35) emulator

expo-example dev build (expo prebuild + gradlew app:assembleDebug, arm64; datetimepicker 9.1.0 and slider 5.2.0 linked) on a temporary Pixel 9 AVD (google_apis arm64 image). Metro was on 8081 and the channel server on 7007, with a websocket listener counting updateStoryArgs. Screenshots are in ~/Developer/test-evidence/android-960/.

Native path (.rnstorybook, no warnings):

  • Date (01–05): the date dialog opens. CANCEL sends 0 updates; picking the 10th and pressing OK sends exactly 1. The time dialog (text mode, 14:35): CANCEL sends 0, OK sends exactly 1 and keeps the date (1983-02-10T14:35). A double tap on the date or time button opens one dialog, and one CANCEL clears it. Dismissing with back resets the guard, so the next tap opens it again.
  • Range (06): switching Basic → Range with the panel open shows second = 7. Tapping the native slider's track applies (first → 37).
  • Color, select, radio, boolean, object, array (08–11, 17–21): all apply. Invalid object JSON shows the error style and sends no update.
  • Keyboard (13–16, with Gboard): the sheet moves above the keyboard with no jump, and the focused text or number input stays visible. Typing applies (name → Storytellerxx, second → 39).
  • Dark mode (22–24): the panel and both native dialogs follow dark mode.
  • Lite UI (EXPO_PUBLIC_LITE_UI=true, 25–27): dragging the native slider applies (first → 37), and the date dialog applies one update.

Fallback path (temporarily patched isDateTimePickerNativeAvailable/isSliderNativeAvailable in dist to return false, restored afterwards):

  • Date and time inputs (28–30): 2024-13-40 and 25:99 show the error border and send nothing. 2024-07-15 and 09:45 apply.
  • Fixed in ce7cc10: submitting a fallback field from the keyboard sent the same updateStoryArgs twice. Submit committed the value, and the blur that follows the submit committed it again against the stale date. Each field now remembers the text it last applied. After the fix, one submit sends one update, and blur alone still sends one. A new types/Date.test.tsx covers submit+blur, time-keeps-date and invalid input; it fails without the fix. This logic isn't Android-specific, so the iOS fallback should behave the same way.
  • JS slider (31/32): dragging inside the sheet applies (first → 34).
  • Warnings: in one session, visiting the date, range and ControlExample stories several times logged the datetimepicker warning once and the slider warning once.

Stub path (.rnstorybook-nofactories, lists @storybook/addon-ondevice-controls; 33/34): exactly one Controls tab, the "no longer needed" warning logged once, and args are editable (second → 8, 6 x 8 = 48).

Known issue, not fixed here (already on next): in the full UI on Android, dragging the native @react-native-community/slider inside the addons bottom sheet doesn't move it, though tapping the track works (07-native-slider-dragged.png). The same drag works on a native slider outside the sheet (the color picker modal, 10-color-picker-sat.png), in the lite UI, and on the JS fallback slider inside the sheet. So the gorhom BottomSheetModal content pan gesture is probably taking the horizontal move from the native SeekBar. next's addon renders the same native slider in the same sheet, and this PR doesn't touch react-native-ui. A possible follow-up is failOffsetX/activeOffsetY on the BottomSheetModal in MobileAddonsPanel.

Screenshots

The screenshots are saved locally on the dev machine in ~/Developer/test-evidence/builtin-controls-v2/ and aren't attached here:

  • 02-light-date-sheet.png, 06-dark-date-sheet.png, 07-dark-time-sheet.png, and a side-by-side light/dark comparison in sheets-light-dark.png
  • 03/04 (arg changed while open, reopen), 05-* (confirm recording and frames), 09-lite-dark-date-sheet.png
  • 10/11 (stub path)
  • 20–26 (Android)

There's no fork remote, so I didn't upload them anywhere public from the CLI. I can attach them if wanted.

Notes

  • The first Android pass used Expo Go. The "Android: dev build" section above covers a dev-client build.
  • agent-device's fill sometimes cleared or typed only part of a short text field; retyping worked. This is a driver quirk, not a Storybook issue.

Add the Controls panel to @storybook/react-native, registered from start()
when features.ondeviceControls is set. It uses the same RNCONTROLS addon id as
@storybook/addon-ondevice-controls, so the built-in registration replaces the
package's if both are present.

@react-native-community/datetimepicker and @react-native-community/slider are
optional peers. Each is loaded with a guarded require and only used when its
native side is linked (RNDateTimePicker view on iOS, RNCDatePicker and
RNCTimePicker modules on Android, RNCSlider view). Otherwise the date control
falls back to validated YYYY-MM-DD and HH:mm text inputs, and range controls to
the JS slider. react-native-modal-datetime-picker is replaced by a small modal
around the native iOS picker and DateTimePickerAndroid.open on Android.

Enable the flag in the expo example, and document it.
@changeset-bot

changeset-bot Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c7f9ae0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@storybook/addon-ondevice-controls Major
@storybook/react-native Major
@storybook/react-native-ui Major
@storybook/react-native-ui-lite Major
@storybook/addon-ondevice-actions Major
@storybook/addon-ondevice-backgrounds Major
@storybook/addon-ondevice-notes Major
@storybook/react-native-theming Major
@storybook/react-native-ui-common Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

…trols a stub

Register the built-in Controls panel unless features.ondeviceControls is false.
@storybook/addon-ondevice-controls becomes a no-op stub that keeps its exports
and warns once in development; its duplicated source and dependencies are
removed. getControlArgTypes and the addon constants are exported from
@storybook/react-native/controls.

Bring the iOS date picker sheet in line with react-native-modal-datetime-picker:
take the value only when it opens, keep the mode while it closes, animate the
backdrop and sheet, respect the bottom safe area and label the buttons. Guard
against stacking Android dialogs.

Read a story's args during the render that switches to it, and reset the form
per story, so number inputs no longer start empty after a story change.
@dannyhw dannyhw changed the title feat: built-in on-device controls addon behind ondeviceControls flag feat: built-in on-device controls, on by default; addon-ondevice-controls becomes a stub Nov 7, 2026
Submitting a YYYY-MM-DD or HH:mm fallback input also blurs it, and both
committed the value, so the same updateStoryArgs was sent twice (seen on
Android). Each field now remembers the text it last applied and skips
re-applying it.
…controls

Brings in Storybook 11.0.0-beta.0, Expo SDK 58 and React Native 0.88 (#958), glob 14 (#962), url-polyfill v4 (#953) and the FEATURES spread in generated requires (#945).

- #958's controls type fixes (TextInputRef, the typed TextInputWithSwitcher and Input, the immutable Object error style, PanResponderInstance, the ComponentRef picker ref) now live in packages/react-native/src/controls, where the controls code moved.
- packages/ondevice-controls/package.json keeps the stub's dependencies and peers.
- packages/react-native/package.json takes next's versions plus this branch's controls dependencies; the datetimepicker and slider dev versions move to 9.2.1 and 5.2.1 to match Expo SDK 58.
- pnpm-lock.yaml is next's lockfile reinstalled.
The controls type fixes now ship in @storybook/react-native, and the addon-ondevice-controls stub has none of that code.
Port the check/inline-check control (#972) and the number control fix (#965)
to the built-in controls in packages/react-native/src/controls. Move the
check control's PropField tests into @storybook/react-native, since
PropField is no longer reachable from the addon-ondevice-controls stub, and
retarget the check-control and number-control changesets to
@storybook/react-native.
The stub has no DOM or node types, and after the Storybook 11.0.0-beta.3
update nothing pulls them in transitively, so tsc no longer finds console.
Declare it the same way the actions stub does.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant