[EuiFlyout] Resizable flyout with a numeric `size` rescales on container resize instead of preserving the user's pixel width
- Dominant language
- TypeScript
- Stars
- 6.4k
- Forks
- 911
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 65
Description
## Relationship to #9683 (please read first)
This issue and #9683 fire on **the same event** — the flyout container's width changing — and request **opposite outcomes**:
- **#9683** (main + child pair jumps to stacked mode): wants a manually-resized flyout to be *unresized*, reverting to its coded `s`/`m` size.
- **This issue** (Discover's document flyout): wants a manually-resized flyout to *keep the user's pixel width*, and let the sibling content absorb the leftover space.
Both are valid, and they don't actually conflict — they're different consumer contracts. `typeof size === 'number'` is the discriminator that satisfies both:
| `size` prop | Consumer contract | Correct behavior on container resize |
| --- | --- | --- |
| `'s'` / `'m'` / `'l'` / `'fill'` | Percentage semantics (see below) | Scale / revert — **#9683** |
| numeric (e.g. `544`) | Consumer manages pixels and persists them | Re-clamp only, preserve pixels — **this issue** |
Filing separately because the root cause and the fix are specific and testable. Happy to fold this into #9683 if the team prefers a single thread.
## Describe the bug
A resizable flyout given a **numeric** `size` does not keep the width the user dragged it to. When the container width changes, the flyout is rescaled to preserve its *percentage* of the container.
For a `type="push"` flyout this is especially visible, because push mode sets `padding-inline-end` on the container from a `ResizeObserver` measurement. So a single container resize moves **both** panes: the flyout rescales, and the content beside it reflows to match — a frame or two later.
In Kibana this is Discover's document details flyout. Kibana scopes all flyouts to the app workspace container (`#app-main-scroll`), so a window resize, a sidebar resize, or opening the AI Assistant all change `referenceWidth` and therefore rescale the flyout. The data grid (histogram + virtualized table) re-lays-out alongside it.
There's a secondary effect: `callOnResize` is left `true` after a drag ends, so these non-user rescales also fire `onResize`. Consumers that persist the callback value (Discover writes it to `localStorage`) have the user's stored width **permanently overwritten** by a window resize.
## Root cause
`packages/eui/src/components/flyout/use_flyout_resizable.ts` — the constraint-change branch multiplies the current pixel width by the reference-width ratio:
```ts
} else {
const prevRefWidth = prevReferenceWidthRef.current ?? _referenceWidth;
prevReferenceWidthRef.current = _referenceWidth;
setFlyoutWidth((currentWidth) => {
if (currentWidth && prevRefWidth > 0 && _referenceWidth > 0) {
const scaleFactor = _referenceWidth / prevRefWidth;
return getFlyoutMinMaxWidth(currentWidth * scaleFactor);
}
...
```
The hook then emits the width as a percentage:
```ts
const pctValue = (flyoutWidth / _referenceWidth) * 100;
return `${pctValue}%`;
```
and `flyout.component.tsx` converts it back with `containerRect.width * (pct / 100)`.
## Why the fix must be conditional
Scaling is **correct** for named sizes — EUI defines them as percentages in `packages/eui/src/components/flyout/flyout.styles.ts`:
```
s: width 25%
m: width 50%
l: width 75%
(capped at 90%)
```
So an `m` flyout that stays at 50% through a container resize is behaving as designed, and the existing code comment says so explicitly ("preserves the flyout's percentage position in both directions"). Removing the scale factor unconditionally would be a silent semantic change for every resizable named-size flyout.
A numeric `size`, by contrast, is a pixel contract: the consumer measured, persisted, and re-supplied a pixel value. Scaling it discards exactly the information the consumer is trying to preserve.
## Proposed fix
In the constraint-change branch, branch on the `size` type — clamp for numeric, keep scaling for named:
```ts
} else {
const prevRefWidth = prevReferenceWidthRef.current ?? _referenceWidth;
prevReferenceWidthRef.current = _referenceWidth;
setCallOnResize(false); // container resize is not a user resize
setFlyoutWidth((currentWidth) => {
if (!currentWidth || _referenceWidth <= 0) return currentWidth;
// Numeric `size` is a pixel contract — re-clamp, don't rescale.
if (typeof _size === 'number') return getFlyoutMinMaxWidth(currentWidth);
if (prevRefWidth > 0) {
return getFlyoutMinMaxWidth(currentWidth * (_referenceWidth / prevRefWidth));
}
return currentWidth;
});
}
```
Two notes:
1. **The `%` round-trip is lossless on this path.** `referenceWidth` comes from `useResizeObserver(container, 'width')`, which reports `borderBoxSize.inlineSize`, and the back-conversion uses `containerRect.width` from `getBoundingClientRect()` — both border-box, so `flyoutWidth / referenceWidth * 100` resolves back to the same pixels. Worth confirming: the `container.clientWidth` fallback in `flyout.component.tsx` (used when the observer hasn't reported yet) *is* content-box, so it's off by the container's padding on the first frame — including the push padding this component itself applies.
2. **`setCallOnResize(false)`** on this path stops container resizes from firing `onResize` and corrupting consumer-persisted widths.
An explicit `resizeMode: 'pixel' | 'percent'` prop would be the more discoverable API if the team would rather not infer intent from the `size` type. The inferred version needs no consumer changes.
### Existing tests
`use_flyout_resizable.test.ts` has no coverage of scaling on `referenceWidth` change — every current test uses a static `referenceWidth` and asserts clamping or percentage output. So this change shouldn't break existing tests, and it's worth adding cases for both branches (numeric size holds pixels; named size holds percentage).
## Minimum reproduction
I wasn't able to isolate this in a sandbox, but it reproduces in EUI alone without Kibana:
1. Render a resizable `EuiFlyout` with `type="push"`, a **numeric** `size` (e.g. `544`), and a `container` element narrower than the viewport.
2. Drag the resize handle to a chosen width and note the pixel value.
3. Change the container's width (resize the window, or toggle a sidebar beside it).
4. The flyout's pixel width changes proportionally instead of staying put, the pushed content reflows with it, and `onResize` fires with the scaled value.
Expected: the flyout stays at the dragged pixel width (re-clamped if it no longer fits), only the pushed content resizes, and `onResize` does not fire.
## Impact and severity
- Both panes of a push layout move on every window/sidebar resize, where only one should. In Discover the neighbouring pane is an expensive data grid, so the reflow is very visible.
- The user's persisted width is silently destroyed — this one is permanent, not transient.
- Shared surface, not one app: in Kibana `resizable` is plumbed through the core overlay service (`src/core/packages/overlays/browser-internal/src/flyout/flyout_service.tsx`) and used directly by Discover's doc viewer, `metrics_insights_flyout`, and agent_builder's `canvas_flyout`.
No good consumer-side workaround. Forcing the hook's reset branch (by making `size` change) runs *after* the scale has already applied, producing two reflows instead of one.
## Environment and versions
- EUI version: 119.0.0 (behavior introduced in #9377)
- Kibana version: `main`
- Browser: Chrome / macOS
---
Kibana-side tracking issue (the two consumer bugs that do not need this fix): elastic/kibana#287943
Contributor guide
Research direction
Start in packages/eui/src/components/flyout/use_flyout_resizable.ts and inspect the constraint-change branch, then review flyout.component.tsx and the existing use_flyout_resizable.test.ts coverage. Add cases for numeric and named sizes during referenceWidth changes; done means numeric sizes preserve their pixel width subject to clamping, named sizes preserve percentage behavior, and container resizes do not trigger onResize.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100