Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
86212dc
Add React 19 internal-API migration plan
lbwexler Jun 5, 2026
f0e009a
Reframe plan as React 18-safe React 19 prep (findDOMNode only)
lbwexler Jun 6, 2026
8b12559
Add Popover (PopoverNext) and floating-ui phases as React 18-safe prep
lbwexler Jun 7, 2026
86a2868
Phase 1: remove findDOMNode from HoistInputModel (React 19 prep)
lbwexler Jun 7, 2026
01d2dd8
Move React 19 findDOMNode changelog entry to 87.0.0-SNAPSHOT
lbwexler Jun 7, 2026
0b0e4d7
Phase 2: migrate Popover kit wrapper to Blueprint PopoverNext (React …
lbwexler Jun 7, 2026
483c8c8
Phase 3: migrate mobile Popover from react-popper to Floating UI (Rea…
lbwexler Jun 7, 2026
1f50d2c
Update menuPositionToPlacement doc comment for Floating UI
lbwexler Jun 7, 2026
9b0ed20
Apply forward-compatible React 19 type-prep fixes
lbwexler Jun 7, 2026
1de86c1
Bump to React 19 (deps + peer range)
lbwexler Jun 7, 2026
41881de
Merge remote-tracking branch 'origin/develop' into react-19-upgrade
lbwexler Jun 14, 2026
a77b60b
Merge remote-tracking branch 'origin/develop' into react-19-upgrade
lbwexler Jun 15, 2026
9f942c2
Merge remote-tracking branch 'origin/develop' into react-19-upgrade
lbwexler Jun 15, 2026
05f8190
Required Mobile fixes
lbwexler Jun 15, 2026
e024f62
Required Mobile fixes
lbwexler Jun 15, 2026
49337a1
Checkpoint
lbwexler Jun 16, 2026
63ac191
Merge remote-tracking branch 'origin/develop' into react-19-upgrade
lbwexler Jun 17, 2026
6702936
feedback from tom tirapani
lbwexler Jun 19, 2026
f3b121b
Merge remote-tracking branch 'origin/develop' into react-19-upgrade
lbwexler Jun 19, 2026
073c9b0
Remove extra comment
lbwexler Jun 19, 2026
6a04f99
Merge remote-tracking branch 'origin/develop' into react-19-upgrade
lbwexler Jun 23, 2026
509afa9
Merge remote-tracking branch 'origin/develop' into react-19-upgrade
lbwexler Jul 2, 2026
1b9eb98
update yarn.lock
lbwexler Jul 2, 2026
9e8f292
update yarn.lock
lbwexler Jul 2, 2026
2e4f540
Merge remote-tracking branch 'origin/develop' into react-19-upgrade
lbwexler Jul 15, 2026
c581537
update yarn.lock
lbwexler Jul 15, 2026
d358602
update changelog for release
lbwexler Jul 15, 2026
e026ce0
Final pre-merge tweaks
lbwexler Jul 15, 2026
49ae59f
Fix regression to Filter Chooser.
lbwexler Jul 15, 2026
be5fbf2
Merge remote-tracking branch 'origin/develop' into react-19-upgrade
lbwexler Jul 15, 2026
4edf23c
Merge branch 'develop' into react-19-upgrade
lbwexler Jul 15, 2026
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
22 changes: 20 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,35 @@

## 87.0.0-SNAPSHOT - unreleased

### 💥 Breaking Changes (upgrade difficulty: 🟠 MEDIUM - React 19 upgrade.)
* Hoist v87 updates to React 19. Applications may require minor adjustments and should be
carefully tested.
* Apply any type adjustments needed to meet React 19's stricter typing. See
https://react.dev/blog/2024/04/25/react-19-upgrade-guide#typescript-changes for more info.
* Both desktop and mobile `Popover` implementations now render on Floating UI, rather than
Popper.js, which is not React-19 compatible. This changes the underlying DOM and CSS classes
for popovers. Test popover-based UI (menus, selects, date inputs, filter choosers) and adjust
any custom styling that targeted Blueprint or Popper css classes (e.g. `bp6-minimal`).
* The `popperOptions` `popper.js` escape-hatch prop has been removed from the mobile `Popover`.

### ⚙️ Technical
* Moved both desktop and mobile popover implementations off the deprecated, React-18-capped Popper.js
onto Floating UI for React 19 compatibility. The hoist `Popover` components (mobile and desktop)
have been updated so no app call-site changes are required.
* Applied type adjustments to meet React 19's stricter `@types/react` typing.

### 📚 Libraries
* react `18.2 → 19.2`

## 86.4.0 - 2026-07-15

### 🎁 New Features

* Added `PrefService.isSet()` to report whether the current user has an explicit value on file for a
preference vs. receiving its server-side default - a distinction that cannot be reliably inferred
by comparing the value to the default. Requires a hoist-core version that emits the backing
`isSet` flag; against older servers all prefs report as unset.

### 🐞 Bug Fixes

* `PrefService.unset()` now performs a true server-side unset, clearing the user's stored value so
the preference reverts to its (possibly changing) default and `isSet()` reports `false`.
Previously it persisted the current default as an explicit user value. Falls back to the legacy
Expand Down
3 changes: 2 additions & 1 deletion cmp/grid/columns/Column.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
forwardRef,
FunctionComponent,
isValidElement,
ReactElement,
ReactNode,
useImperativeHandle,
useLayoutEffect,
Expand Down Expand Up @@ -1062,7 +1063,7 @@ export class Column {
};
// Can be a component or elem factory/ ad-hoc render function.
if ((editor as any).isHoistComponent) return createElement(editor, props);
if (isFunction(editor)) return editor(props);
if (isFunction(editor)) return editor(props) as ReactElement;
throw XH.exception('Column editor must be a HoistComponent or a render function');
});
ret.cellEditorPopup = this.editorIsPopup;
Expand Down
11 changes: 4 additions & 7 deletions cmp/input/HoistInputModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ import {action, computed, makeObservable, observable} from '@xh/hoist/mobx';
import {createObservableRef} from '@xh/hoist/utils/react';
import classNames from 'classnames';
import {isEqual} from 'lodash';
import {FocusEvent, ForwardedRef, ReactElement, ReactInstance, useImperativeHandle} from 'react';
import {findDOMNode} from 'react-dom';
import {FocusEvent, ForwardedRef, ReactElement, useImperativeHandle} from 'react';
import './HoistInput.scss';

/**
Expand Down Expand Up @@ -72,10 +71,8 @@ export class HoistInputModel extends HoistModel {
* root of the rendered component sub-tree.
*/
get domEl(): HTMLElement {
const current = this.domRef.current as ReactInstance;
return (
!current || current instanceof Element ? current : findDOMNode(current)
) as HTMLElement;
const {current} = this.domRef;
return current instanceof Element ? (current as HTMLElement) : null;
}

/**
Expand Down Expand Up @@ -103,7 +100,7 @@ export class HoistInputModel extends HoistModel {
//------------------------
@observable.ref internalValue: any = null; // Cached internal value
inputRef = createObservableRef<HTMLElement>(); // ref to internal <input> element, if any
domRef = createObservableRef<HTMLElement>(); // ref to outermost element, or class Component.
domRef = createObservableRef<HTMLElement>(); // ref to outermost rendered DOM element.
isDirty: boolean = false;

constructor() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,12 @@ function createDraggableItems(dashCanvasModel: DashCanvasModel): ReactNode[] {
firstIcon = entries[0]?.icon,
icon =
firstIcon &&
every(entries, it => it.icon?.props.iconName === firstIcon.props.iconName)
every(
entries,
it =>
(it.icon?.props as {iconName?: string})?.iconName ===
(firstIcon.props as {iconName?: string}).iconName
)
? firstIcon
: null;

Expand Down
4 changes: 2 additions & 2 deletions desktop/cmp/filter/FilterChooser.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
}
}

// Extra class names required to override the default styles of the popover.
&__popover.bp6-popover.bp6-minimal {
// Override the default styles of the popover, scoped by our own `popoverClassName`.
&__popover.bp6-popover {
box-shadow: none;

// Overlay the expanded input onto the collapsed 30px trigger. Offset follows Blueprint's
Expand Down
5 changes: 4 additions & 1 deletion desktop/cmp/input/CodeInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,10 @@ const inputCmp = hoistCmp.factory<CodeInputModel>(({model, ...props}, ref) =>
div({
className: 'xh-code-input__inner-wrapper',
// We pass the container via ref to createCodeEditor, which initializes the editor inside it.
ref: model.createCodeEditor
// Wrapped to return void — React 19 treats a ref callback's return value as a cleanup fn.
ref: el => {
model.createCodeEditor(el);
}
}),
model.showToolbar ? toolbarCmp() : actionButtonsCmp()
],
Expand Down
2 changes: 1 addition & 1 deletion desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ const tabCmp = hoistCmp.factory<TabProps>(({tab, index, localModel, model}) => {
isCloseable =
tab.disabled ||
model.enabledVisibleTabs.filter(it => it instanceof TabModel).length > 1,
tabRef = useRef<HTMLDivElement>(),
tabRef = useRef<HTMLDivElement>(null),
scrollerModel = useContextModel(ScrollerModel),
{showScrollButtons} = scrollerModel,
{disabled, icon, tooltip} = tab,
Expand Down
4 changes: 2 additions & 2 deletions desktop/cmp/tab/dynamic/scroller/Scroller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {button} from '@xh/hoist/desktop/cmp/button';
import {ScrollerModel} from '@xh/hoist/desktop/cmp/tab/dynamic/scroller/ScrollerModel';
import {Icon} from '@xh/hoist/icon';
import {composeRefs, useOnResize} from '@xh/hoist/utils/react';
import React, {Ref} from 'react';
import React, {ReactElement, Ref} from 'react';

/**
* A scroller component that displays a content component with directional scroll buttons when the
Expand Down Expand Up @@ -38,7 +38,7 @@ export const [Scroller, scroller] = hoistCmp.withFactory<ScrollerProps>({
contentRef,
useOnResize(() => model.onViewportEvent())
)
}),
}) as ReactElement,
scrollButton({direction: 'forward', model})
]
});
Expand Down
2 changes: 1 addition & 1 deletion desktop/hooks/UseContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {cloneElement, isValidElement, MouseEvent, ReactElement} from 'react';
* @param spec - spec the menu to be shown. If null, or the number of items is empty, no menu will
* be rendered and the event will be consumed.
*/
export function useContextMenu(child?: ReactElement, spec?: ContextMenuSpec): ReactElement {
export function useContextMenu(child?: ReactElement<any>, spec?: ContextMenuSpec): ReactElement {
if (!child || isUndefined(spec)) return child;

const onContextMenu = (e: MouseEvent | PointerEvent) => {
Expand Down
2 changes: 1 addition & 1 deletion desktop/hooks/UseHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {HotkeyConfig} from '@blueprintjs/core/src/hooks/hotkeys/hotkeyConfig';
* @param hotkeys - An array of hotkeys, or configs for hotkeys,
* as prescribed by blueprint. A Hotkeys element may also be provided.
*/
export function useHotkeys(child?: ReactElement, hotkeys?: HotkeyConfig[]) {
export function useHotkeys(child?: ReactElement<any>, hotkeys?: HotkeyConfig[]) {
if (!child || isEmpty(hotkeys)) return child;

const memoHotkeys = useMemo(() => hotkeys, []),
Expand Down
108 changes: 108 additions & 0 deletions docs/planning/react-19-upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# React 19 upgrade

**Branch:** `react-19-upgrade` (based on `develop`) — see [#4205](https://github.com/xh/hoist-react/issues/4205).

**Outcome:** hoist-react now type-checks and runs against React 19. This is a clean cut — the
`react` / `react-dom` peer dependency moves to `^19.2.0` and React 18 is no longer supported.
Consuming apps upgrade to React 19 alongside this Hoist major.

## Context

A scan of hoist-react for React-19-affected internal APIs found the surface small, and most of it
was already handled before this branch:

- `ReactDOM.render` / `hydrate` / `unmountComponentAtNode` — **none** (already on `createRoot`, in
`appcontainer/AppContainerModel.ts` and `desktop/cmp/dash/container/DashContainerModel.ts`).
- legacy Blueprint `Overlay` — already migrated to `Overlay2` in `kit/blueprint/Wrappers.ts`.
- `defaultProps` on function components — none.
- Legacy context (`childContextTypes` / `getChildContext`) — none.
- String refs / `element.ref` access / `props.ref` reads — none.
- `cloneElement` is used in several places but never to forward a `ref`, so the React 19
`cloneElement` ref-handling change does not apply.

That left the work below: two removed/broken APIs (`findDOMNode`, Popper.js-based popovers), the
custom-element boolean-prop change that affects the Onsen kit, the types/peer bump, and the
type-only fallout from `@types/react@19`.

## Types / peer bump

- `package.json`: `peerDependencies.react` / `react-dom` → `^19.2.0` (React 18 dropped);
dev `react` / `react-dom` → `^19.2.0`; `@types/react` / `@types/react-dom` and their
`resolutions` pins → `19.x`.
- Removed `react-popper` as a direct dependency; added `@floating-ui/react` (see below).

## `findDOMNode` removal

`findDOMNode` is removed in React 19. `HoistInputModel.domEl` (`cmp/input/HoistInputModel.ts`)
used it as a fallback for when `domRef.current` resolved to a class instance rather than a DOM
element. A trace of all desktop + mobile HoistInput implementations confirmed every one roots
`domRef` on a DOM element, so the fallback was dead defensive code. `domEl` now resolves the
element directly from the ref; the `findDOMNode` / `ReactInstance` imports are dropped.

## Popovers off Popper.js → Floating UI

Both popover implementations relied on Popper.js, which is React-18-capped and not React 19
compatible.

- **Desktop kit wrapper — `kit/blueprint/Wrappers.ts`.** Blueprint's legacy `Popover` relies on
the removed `findDOMNode`; its Floating UI-based `PopoverNext` (same Blueprint build) is the
replacement — mirroring the existing `Overlay2 as Overlay` pattern. The wrapper now renders
`PopoverNext` and runs incoming props through Blueprint's `popoverPropsToNextProps()` helper,
which maps `position` / `modifiers` / `minimal` / `boundary` and preserves the legacy
`shouldReturnFocusOnClose` default. Because the helper handles the mapping, the `popover`
factory keeps its existing `PopoverProps` API and **no call sites needed edits**.
- **Mobile — `mobile/cmp/popover/Popover.ts`** (the sole `react-popper` consumer). Swapped
`usePopper` for `@floating-ui/react`'s `useFloating`, reusing the Floating UI copy Blueprint's
`PopoverNext` already pulls in rather than adding a separate library. Positioning uses
`middleware: [autoPlacement() | flip(), shift({padding: 10})]` with `whileElementsMounted:
autoUpdate`; placement names are shared with Popper.js so `menuPositionToPlacement` is
unchanged. Element wiring uses Floating UI's own `refs.setReference` / `refs.setFloating`
callback refs rather than the previous observable refs — the MobX-observer re-render the old
approach depended on did not reliably fire under React 19 for the portaled content. The obsolete
Popper.js-specific `popperOptions` escape-hatch prop was removed (breaking change; unused in
Hoist).

## Onsen custom-element boolean props — `kit/onsen/index.ts`

react-onsenui encodes boolean props as the string `''` (or `null`). Under React 18 these were
assigned as DOM *attributes*; React 19 assigns them as DOM *properties* on the underlying custom
element, and Onsen's boolean property setters treat `''` as falsy — so props like `checked`,
`disabled`, and `visible` silently failed to apply. The kit wrapper now strips boolean props from
the rendered props and applies the real booleans imperatively via a ref in `useLayoutEffect`
(accounting for Onsen's deprecated aliases `isOpen`/`isCancelable`/`isDisabled`), routing through
Onsen's own setters.

## `@types/react@19` type adjustments

Type-only fallout, no runtime behavior change:

- `useRef` now requires an initial arg — `desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts`
(`useRef<HTMLDivElement>(null)`).
- Ref-callback return values are treated as cleanup functions — `desktop/cmp/input/CodeInput.ts`
wraps its `createCodeEditor` ref callback to return `void` (the method is `async`, so it would
otherwise return a Promise that React invokes as a destructor).
- `ReactElement.props` is typed `unknown` — `desktop/cmp/dash/canvas/widgetchooser/DashCanvasWidgetChooser.ts`
narrows icon `props` before reading `iconName`.
- Widened FC return type / stricter element typing — casts in `cmp/grid/columns/Column.ts` and
`desktop/cmp/tab/dynamic/scroller/Scroller.ts` (`as ReactElement`), and `ReactElement<any>` in
`desktop/hooks/UseContextMenu.ts` and `desktop/hooks/UseHotkeys.ts`.

## Not done (still valid on React 19)

- **`forwardRef` → `ref`-as-prop.** `forwardRef` is *deprecated* in React 19, not removed — it
still works, so it never blocked this upgrade. Left in place as future modernization cleanup;
sites include `core/HoistComponent.ts` (the framework-wide `cfg.isForwardRef` ref wrap — the
delicate one), `cmp/grid/columns/Column.ts`, and `kit/onsen/index.ts`.
- **`propTypes`** — React 19 ignores them (not an error). No runtime `propTypes` definitions exist
in the codebase; only a stale comment reference in `desktop/cmp/button/index.ts`.

## Verification

- `tsc --noEmit` clean against `@types/react@19`.
- `yarn lint`.
- Smoke in toolbox via `yarn startWithHoist`:
- inputs and anything reading `HoistInputModel.domEl` (focus, autofocus, sizing/measurement);
- desktop popovers: Select, DateInput, combos, column chooser, ViewManager menu, mobile
MenuButton — placement, dismissal, and focus-return behavior;
- mobile Popover positioning, flip/shift near viewport edges, scroll/resize updates;
- Onsen-backed mobile controls with boolean state (checkboxes, switches, dialog visibility).
8 changes: 6 additions & 2 deletions kit/blueprint/Wrappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import {
NumericInput,
OverflowList,
Overlay2 as Overlay,
Popover as BpPopover,
PopoverNext as BpPopover,
popoverPropsToNextProps,
type PopoverProps,
Radio,
RadioGroup,
Expand All @@ -61,8 +62,11 @@ import React, {createElement as reactCreateElement} from 'react';
const Dialog: React.FC<DialogProps> = props =>
reactCreateElement(BpDialog, {transitionDuration: 0, transitionName: 'none', ...props});

// `Popover` renders Blueprint's `PopoverNext` (Floating UI based, React 19 compatible) while
// preserving our legacy `PopoverProps` API: `popoverPropsToNextProps` maps `position`/`modifiers`/
// `minimal`/`boundary` and retains the legacy `shouldReturnFocusOnClose` default of `false`.
const Popover: React.FC<PopoverProps> = props =>
reactCreateElement(BpPopover, {transitionDuration: 0, ...props});
reactCreateElement(BpPopover, {transitionDuration: 0, ...popoverPropsToNextProps(props)});
Comment thread
lbwexler marked this conversation as resolved.

//---------------------
// Re-exports
Expand Down
45 changes: 32 additions & 13 deletions kit/onsen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import {ElementFactory, elementFactory, HoistModel} from '@xh/hoist/core';
import onsen from 'onsenui';
import 'onsenui/css/onsen-css-components.css';
import 'onsenui/css/onsenui.css';
import {createElement, forwardRef, FunctionComponent} from 'react';
import {composeRefs} from '@xh/hoist/utils/react';
import {createElement, forwardRef, FunctionComponent, useLayoutEffect, useRef} from 'react';
import * as ons from 'react-onsenui';
import {omitBy} from 'lodash';
import {mapKeys, omitBy, pickBy} from 'lodash';
import './styles.scss';
import './theme.scss';

Expand Down Expand Up @@ -42,23 +43,41 @@ export const [dialog, Dialog] = wrappedCmp(ons.Dialog),
//-----------------
// Implementation
//-----------------
// Onsen's deprecated boolean prop aliases - react-onsenui remaps these to the real custom-element
// property names before assigning. We replicate it here since we bypass that step for booleans.
const ONSEN_BOOL_ALIASES = {isOpen: 'visible', isCancelable: 'cancelable', isDisabled: 'disabled'};

/**
* Wrappers around ElementFactory and ContainerElementFactory that strip
* HoistModel props before passing onto the Onsen component.
* Wrapper around ElementFactory that adapts an Onsen component for use within Hoist.
*
* Onsen component props are internally serialized to JSON. If it receives a HoistModel as a prop,
* it can easily cause a circular structure error due to the complexity of the model. For example,
* any HoistModel that implements LoadSupport will create a 'target' reference to itself.
* Apps can readily introduce other structures incompatible with JSON serialization.
* Strips HoistModel props before passing them on. Onsen serializes props to JSON internally, and a
* an Onsen component never needs a HoistModel prop.
*
* There is no reason for an Onsen Component to ever receive a HoistModel prop, so we can safely
* strip them out here.
* Applies boolean props imperatively as real booleans via a ref. react-onsenui encodes boolean
* props as the string `''` (or `null`), which React 19 assigns as a *property* on the underlying
* custom element rather than as an *attribute* (as React 18 and earlier did). Onsen's boolean property
* setters treat `''` as falsy, so props such as `checked`, `disabled`, and `visible` silently fail
* to apply. Setting the real boolean on the element after commit routes through Onsen's own setters.
*/
function wrappedCmp(rawCmp): [ElementFactory, FunctionComponent] {
const cmp = forwardRef((props, ref) => {
const safeProps = omitBy(props, it => it instanceof HoistModel);
if (ref) safeProps.ref = ref;
return createElement(rawCmp, safeProps);
// 1) Gather the boolean props, accounting for aliased keys.
// We'll apply these values directly on underlying onsen component after render.
const elemRef = useRef(null);
const boolProps = mapKeys(
pickBy(props, it => typeof it === 'boolean'),
(_v, key) => ONSEN_BOOL_ALIASES[key] ?? key
Comment thread
lbwexler marked this conversation as resolved.
);
useLayoutEffect(() => {
if (elemRef.current) Object.assign(elemRef.current, boolProps);
});

// 2) Set remaining props on the underlying component, including our ref.
const childProps = {
...omitBy(props, it => it instanceof HoistModel || typeof it === 'boolean'),
ref: composeRefs(elemRef, ref)
};
return createElement(rawCmp, childProps);
});
return [elementFactory(cmp), cmp];
}
Loading
Loading