Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## 87.0.0-SNAPSHOT - unreleased

### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW)

* Removed the `popperOptions` escape-hatch prop from the mobile `Popover`, which exposed
Popper.js-specific internals that no longer exist after its migration to Floating UI (see
Technical). This prop was not used within Hoist.

### ⚙️ Technical

* Removed the last internal use of the deprecated React `findDOMNode` API (in
`HoistInputModel.domEl`) in preparation for React 19, which removes it. All `HoistInput`
implementations already root their `domRef` on a DOM element, so behavior is unchanged.
* Migrated the Blueprint `Popover` kit wrapper to Blueprint's Floating UI-based `PopoverNext`,
which (unlike the legacy `Popover`) is React 19 compatible. The `popover` factory keeps its
existing `PopoverProps` API — `position`, `modifiers`, `minimal`, and `boundary` are mapped
internally and the legacy `shouldReturnFocusOnClose` default is preserved — so no call-site or
application changes are required.
* Migrated the mobile `Popover` from the deprecated, React-18-capped `react-popper` (Popper.js) to
Floating UI (`@floating-ui/react`, the same library already pulled in by Blueprint's
`PopoverNext`), removing `react-popper` as a direct dependency. Positioning behavior is
preserved.

## 86.0.0-SNAPSHOT - unreleased

### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW)
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
130 changes: 130 additions & 0 deletions docs/planning/react-19-upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Plan: React 19 preparation (forward-compatible, stay on React 18)

**Branch:** `react-19` (based on `develop`)

**Goal:** remove the things in hoist-react's own code that will *block* a future React 19
migration, **without upgrading off React 18**. Every change here is forward-compatible — it
behaves identically on React 18 and simply drops an API or component that React 19 removes /
breaks. The actual React 19 upgrade (runtime bump, dependency work) happens later and is tracked
in [#4205](https://github.com/xh/hoist-react/issues/4205).

**Why these are all React-18-safe:** each phase swaps a React-19-incompatible API for a
replacement that *also* runs on React 18. The precedent is already in the tree —
`kit/blueprint/Wrappers.ts` runs `Overlay2 as Overlay` on React 18 today. Phases 2 and 3 apply
that same pattern to `PopoverNext` and `@floating-ui/react`.

## Context

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

- `ReactDOM.render` / `hydrate` / `unmountComponentAtNode` — **none** (already migrated to
`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 leaves three forward-compatible blockers to remove, sequenced as phases below.

## Phase 1 — `findDOMNode` removal (required)

`findDOMNode` is **deprecated in React 18** (works, warns under StrictMode) and **removed in
React 19**, so it is a genuine hard blocker. The ref-based replacement behaves identically on
React 18.

**`cmp/input/HoistInputModel.ts`** — import (line ~15) + the `domEl` getter (line ~77):

```ts
const current = this.domRef.current as ReactInstance;
return (!current || current instanceof Element ? current : findDOMNode(current)) as HTMLElement;
```

- The `findDOMNode` branch only fires when `domRef.current` is a class / `ReactInstance` rather
than an `Element`. Inputs already place `domRef` on the rendered root, so resolve the element
directly from the ref.
- Replace the fallback; if a non-`Element` ever appears, surface it (dev warning) instead of
silently degrading. Drop the now-unused `ReactInstance` import.
- **Verify on React 18:** confirm `domRef.current` always resolves to a DOM `Element` across the
HoistInput implementations now that the class-instance fallback is gone.

## Phase 2 — `Popover` kit wrapper on `PopoverNext` (mirror the `Overlay2` approach)

Blueprint's legacy `Popover` relies on the removed `findDOMNode` and breaks under React 19.
`PopoverNext` is the `findDOMNode`-free replacement in the same Blueprint 6.15.0 package and runs
on React 18 — exactly as `Overlay2` already does. We handle it identically: the kit owns a
`Popover` wrapper that re-exports `PopoverNext` under our canonical name.

- **`kit/blueprint/Wrappers.ts`** — change the import from `Popover as BpPopover` to
`PopoverNext as BpPopover`; point the `PopoverProps` type import at `PopoverNext`'s props. Keep
the thin wrapper that disables the open/close transition by default (verify the right knob on
`PopoverNext` — `transitionDuration: 0` may become `animation`-based; see prop map below).
- Propagate the `PopoverNext` prop renames to call sites that pass Popover props:
- **`desktop/cmp/input/DateInput.ts`** (`minimal`, `modifiers`, `position`, `boundary`,
`onInteraction`)
- **`desktop/cmp/input/Picker.ts`** (`minimal`, `position`, `onInteraction`) — shared base for
Select/combo inputs
- **`desktop/cmp/viewmanager/ViewMenu.ts`** (verify — `shouldDismissPopover` is a `MenuItem`
prop, likely unaffected)
- **`mobile/cmp/menu/MenuButton.ts`** (`popoverProps?: Partial<PopoverProps>` passthrough)
- Prop map (Blueprint v6.14 guide): `position` -> `placement`; `modifiers` -> `middleware` (via
`popperModifiersToNextMiddleware()`); `minimal={true}` -> `animation="minimal"` +
`arrow={false}`; `boundary="clippingParents"` -> `"clippingAncestors"`.
- **Behavior watch:** `shouldReturnFocusOnClose` default flips `false` -> `true` — verify
Select/DateInput focus return doesn't regress.
- Enable the `@typescript-eslint/no-deprecated` rule to surface any remaining deprecated
Blueprint usages.
- **React 18 safe:** `PopoverNext` ships in the current Blueprint build and runs on React 18
(same as `Overlay2`). No runtime bump required.

## Phase 3 — `react-popper` -> `@floating-ui/react`

`react-popper` is deprecated, its repo archived, and its peer dep caps at React 18 — a genuine
code incompatibility with React 19. `@floating-ui/react` supports React 16.8+, so this is a
like-for-like swap that runs on React 18.

- **`mobile/cmp/popover/Popover.ts`** is the sole consumer (`usePopper` at line ~93; results used
for `popper.styles.popper` at line ~126; `ReactDom.createPortal` at line ~120 stays — unchanged
in React 19).
- Replace `usePopper` with `useFloating` + `autoUpdate` + `offset` / `flip` / `shift`
middleware; map `styles.popper` / `attributes` to floating-ui's `floatingStyles`.
- Add `@floating-ui/react` dependency; remove `react-popper` (and the `react-popper` entry in
`package.json`).
- **React 18 safe:** floating-ui runs on React 18; no runtime bump required.

## Verification (each phase)

- `tsc --build` clean (still against `@types/react@18`).
- `yarn lint` (incl. `no-deprecated` after Phase 2).
- Smoke in toolbox via `yarn startWithHoist`:
- Phase 1 — inputs / anything reading `HoistInputModel.domEl` (focus, autofocus,
sizing/measurement); confirm no `findDOMNode` StrictMode warnings remain.
- Phase 2 — desktop popovers: Select, DateInput, combos, column chooser, ViewManager menu,
mobile MenuButton; check placement, dismissal, and focus-return behavior.
- Phase 3 — mobile Popover positioning, flip/shift near viewport edges, scroll/resize updates.

## Deferred to the actual React 19 migration (NOT in this branch)

### `forwardRef` -> `ref`-as-prop
Left for the migration for two reasons:

1. **Not possible on React 18.** `ref`-as-a-regular-prop is a React-19-only feature; stripping
`forwardRef` while on 18 breaks ref forwarding (function components on 18 don't receive `ref`
through props).
2. **Not a blocker.** `forwardRef` is *deprecated* in React 19, not removed — it still works, so
it never blocks the migration. It is pure modernization cleanup to do after landing on 19.

Sites for later: `core/HoistComponent.ts` (the `cfg.isForwardRef` path — the framework-wide ref
wrap, the delicate one), `cmp/grid/columns/Column.ts` (x2), `kit/onsen/index.ts`.

### Types / peer bump
`@types/react` / `@types/react-dom` -> `19.x` and widening `peerDependencies.react` /
`react-dom` to `~18.2.0 || ^19` belong with the runtime upgrade. Bumping types ahead of the
React 18 runtime only produces phantom type errors that do not reflect runtime behavior.

### Minor / no-op
`propTypes` — React 19 ignores them (not an error). Only a stale comment reference in
`desktop/cmp/button/index.ts`; no runtime `propTypes` definitions found. Leave as-is.
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)});

//---------------------
// Re-exports
Expand Down
54 changes: 22 additions & 32 deletions mobile/cmp/popover/Popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,23 @@
*
* Copyright © 2026 Extremely Heavy Industries Inc.
*/
import {div, fragment} from '@xh/hoist/cmp/layout';
import {
Content,
hoistCmp,
HoistModel,
HoistProps,
PlainObject,
useLocalModel,
XH
} from '@xh/hoist/core';
autoPlacement,
autoUpdate,
flip,
type Placement,
shift,
useFloating
} from '@floating-ui/react';
import {div, fragment} from '@xh/hoist/cmp/layout';
import {Content, hoistCmp, HoistModel, HoistProps, useLocalModel, XH} from '@xh/hoist/core';
import '@xh/hoist/mobile/register';
import {action, makeObservable, observable} from '@xh/hoist/mobx';
import {createObservableRef, elementFromContent} from '@xh/hoist/utils/react';
import classNames from 'classnames';
import {isFunction, isNil} from 'lodash';
import {ReactPortal} from 'react';
import ReactDom from 'react-dom';
import {usePopper} from 'react-popper';

import './Popover.scss';

Expand Down Expand Up @@ -62,18 +61,15 @@ export interface PopoverProps extends HoistProps {

/** Optional className applied to the popover content wrapper. */
popoverClassName?: string;

/** Escape hatch to provide additional options to the PopperJS implementation */
popperOptions?: PlainObject;
}

/**
* Popovers display floating content next to a target element.
*
* The API is based on a stripped-down version of Blueprint's Popover component
* that is used on Desktop. Popover is built on top of the Popper.js library.
* that is used on Desktop. Popover is built on top of the Floating UI library.
*
* @see https://popper.js.org/
* @see https://floating-ui.com/
*/
export const [Popover, popover] = hoistCmp.withFactory<PopoverProps>({
displayName: 'Popover',
Expand All @@ -86,23 +82,17 @@ export const [Popover, popover] = hoistCmp.withFactory<PopoverProps>({
disabled = false,
backdrop = false,
position = 'auto',
popoverClassName,
popperOptions
popoverClassName
}) {
const impl = useLocalModel(PopoverModel),
popper = usePopper(impl.targetEl, impl.contentEl, {
placement: impl.menuPositionToPlacement(position),
placement = impl.menuPositionToPlacement(position),
isAuto = placement === 'auto',
{floatingStyles} = useFloating({
placement: isAuto ? undefined : (placement as Placement),
strategy: 'fixed',
modifiers: [
{
name: 'preventOverflow',
options: {
padding: 10,
boundary: 'viewport'
} as any
}
],
...popperOptions
middleware: [isAuto ? autoPlacement() : flip(), shift({padding: 10})],
whileElementsMounted: autoUpdate,
elements: {reference: impl.targetEl, floating: impl.contentEl}
});

return div({
Expand All @@ -123,7 +113,7 @@ export const [Popover, popover] = hoistCmp.withFactory<PopoverProps>({
items: [
div({
ref: impl.contentRef,
style: popper?.styles?.popper,
style: floatingStyles,
className: classNames(
'xh-popover__content-wrapper',
popoverClassName
Expand Down Expand Up @@ -224,8 +214,8 @@ class PopoverModel extends HoistModel {
}

/**
* Convert a menu position to a Popper.js placement.
* This allows us to the same position names as desktop, and is inspired
* Convert a menu position to a Floating UI placement (the vocabulary is shared with
* Popper.js). This allows us to use the same position names as desktop, and is inspired
* by Blueprint's similar implementation:
* https://github.com/palantir/blueprint/blob/develop/packages/core/src/components/popover/popoverMigrationUtils.ts
*/
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@codemirror/lint": "~6.9.6",
"@codemirror/state": "~6.6.0",
"@codemirror/view": "^6.43.0",
"@floating-ui/react": "^0.27.13",
"@fortawesome/fontawesome-pro": "^7.2.0",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/pro-light-svg-icons": "^7.2.0",
Expand Down Expand Up @@ -79,7 +80,6 @@
"react-grid-layout": "~2.2.3",
"react-markdown": "~10.1.0",
"react-onsenui": "~1.13.2",
"react-popper": "~2.3.0",
"react-select": "~5.10.2",
"react-window": "~2.2.7",
"react-windowed-select": "~5.2.0",
Expand Down
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7679,7 +7679,7 @@ react-onsenui@~1.13.2:
dependencies:
prop-types "^15.6.0"

react-popper@^2.3.0, react-popper@~2.3.0:
react-popper@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba"
integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==
Expand Down
Loading