Skip to content

[EuiFlyout] Resizable flyout with a numeric size rescales on container resize instead of preserving the user's pixel width #9969

Description

@clintandrewhall

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:

  • [Flyout System] Resizable Flyout Issues #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.

Image

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:

} 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:

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:

} 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


Kibana-side tracking issue (the two consumer bugs that do not need this fix): elastic/kibana#287943

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions