Skip to content

[Perf] Memoize EuiProgress, EuiToken and EuiCard styles - #10183

Merged
Dosant merged 2 commits into
elastic:mainfrom
Dosant:perf/memoize-hot-emotion-styles
Oct 7, 2026
Merged

Dosant merged 2 commits into
elastic:mainfrom
Dosant:perf/memoize-hot-emotion-styles

Conversation

@Dosant

@Dosant Dosant commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Part of #10147, part of #10152
Closes #10153
Closes #10162
Closes #10167

Summary

EuiProgress, EuiToken and EuiCard regenerate their Emotion styles on every render of every instance. For Progress and Token that also means a lot of chroma color math (makeHighContrastColor, tint/shade), which is the expensive part. These are used widely in Kibana in lists: the chrome loading bar and field stats top values (Progress), FieldIcon in the unified field list, doc viewer and data table headers (Token), Fleet integration cards (Card).

This moves them to useEuiMemoizedStyles, so the styles are generated once per theme and shared across all instances:

  • EuiProgress: euiProgressStyles depends on isNative, so I added two static wrappers (euiProgressNativeStyles / euiProgressIndeterminateStyles) and pick one by determinate. Data and value text styles are memoized directly.
  • EuiToken: replaces the per-instance useMemo with one static generator per fill (euiTokenFillStyles).
  • EuiCard: euiCardStyles depends on paddingSize, so there's one static generator per padding size (euiCardStylesByPadding), same as the Token fills. Text styles are memoized directly. euiCardBetaBadgeStyles is left as is since it only runs for cards with a beta badge.
  • EuiCodeBlock: euiCodeBlockLineStyles was called once per line inside the line-wrapping loop; now it's called once per block. Not shared memoization, just hoisting, since it already runs inside a useMemo.

No visual, API or DOM changes. Generators passed to useEuiMemoizedStyles need a name, so the per-variant maps are object literals rather than built with Object.fromEntries.

Benchmark

React Profiler actualDuration (ms, median), Storybook dev build, Chrome, same machine and session, main vs this branch. Single run per side, so treat small differences as noise.

Scenario Before After Change
Mount 200 × EuiProgress (value, valueText, label) 484 83 −83%
Mount 200 × EuiProgress (indeterminate) 421 44 −90%
Mount 500 × EuiToken 316 78 −75%
Mount 500 × EuiToken (mixed fills) 314 81 −74%
Mount 200 × EuiCard 198 121 −39%
Mount 200 × EuiCard with image + icon 262 185 −29%
Mount EuiCodeBlock with ~2,000 lines + line numbers 283 204 −28%
Re-render 200 × EuiProgress 426 80 −81%
Re-render 200 × EuiCard 161 99 −38%
Re-render 500 × EuiToken 74 76 no change

Token re-renders don't change because the old useMemo already covered re-renders of the same instance. The win there is on mount, when many tokens appear at once (field list, table headers).

Methodology
  • A temporary Storybook story (below, not committed) renders each scenario inside a <Profiler> and records actualDuration.
  • Mount: toggle the list on and off 11 times, drop the first (warm-up) sample, take the median.
  • Re-render: 30 synchronous updates via flushSync that change one prop per item, drop the first 3 samples, take the median.
  • Run the stories from iframe.html directly and trigger the buttons from the console. "Before" is the same story with this PR's changes stashed. Both sides ran in the same Storybook session under the same conditions.
  • Absolute numbers are dev-build numbers (React dev mode plus Emotion labels), so production will be faster overall, but the relative difference comes from the same style generation work.
Benchmark story
import React, { Profiler, useState } from 'react';
import { flushSync } from 'react-dom';
import type { Meta, StoryObj } from '@storybook/react-vite';

import { EuiCodeBlock } from './code_block';
import { EuiToken } from '../token';
import { EuiCard } from '../card';
import { EuiIcon } from '../icon';
import { EuiProgress } from '../progress';

declare global {
  interface Window {
    __bench: Record<string, number[]>;
  }
}
window.__bench = window.__bench || {};

const meta: Meta = {
  title: 'Bench/StyleMemo',
  parameters: { layout: 'padded' },
};
export default meta;

const CODE = JSON.stringify(
  Array.from({ length: 400 }, (_, i) => ({ id: i, name: `item ${i}`, ok: true })),
  null,
  2
);
const range = (n: number) => Array.from({ length: n }, (_, i) => i);
const FILLS = ['light', 'dark', 'none'] as const;
const IMG =
  'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>';

const MOUNT: Record<string, () => React.ReactNode> = {
  progress200: () =>
    range(200).map((i) => (
      <EuiProgress key={i} value={i % 100} max={100} valueText label="p" />
    )),
  progressIndeterminate200: () =>
    range(200).map((i) => <EuiProgress key={i} size="xs" />),
  token500: () =>
    range(500).map((i) => <EuiToken key={i} iconType="tokenString" />),
  tokenMixedFill500: () =>
    range(500).map((i) => (
      <EuiToken key={i} iconType="tokenString" fill={FILLS[i % 3]} />
    )),
  card200: () =>
    range(200).map((i) => (
      <EuiCard key={i} title={`Card ${i}`} description="desc" onClick={() => {}} />
    )),
  cardImage200: () =>
    range(200).map((i) => (
      <EuiCard
        key={i}
        title={`Card ${i}`}
        description="desc"
        image={IMG}
        icon={<EuiIcon type="logoElastic" size="l" />}
        onClick={() => {}}
      />
    )),
  codeBlock2000Lines: () => (
    <EuiCodeBlock language="json" lineNumbers>
      {CODE}
    </EuiCodeBlock>
  ),
};

const UPDATE: Record<string, (n: number) => React.ReactNode> = {
  progress200: (n) =>
    range(200).map((i) => (
      <EuiProgress key={i} value={(i + n) % 100} max={100} valueText label="p" />
    )),
  token500: (n) =>
    range(500).map((i) => (
      <EuiToken key={i} iconType="tokenString" title={`t${n}`} />
    )),
  card200: (n) =>
    range(200).map((i) => (
      <EuiCard key={i} title={`Card ${i}`} description={`d${n}`} onClick={() => {}} />
    )),
};

const MountBench = ({ id }: { id: string }) => {
  const [show, setShow] = useState(false);
  return (
    <div>
      <button id={`m-${id}`} onClick={() => setShow((s) => !s)}>
        mount {id}
      </button>
      {show && (
        <Profiler
          id={id}
          onRender={(_, phase, actual) => {
            if (phase === 'mount') (window.__bench[`m-${id}`] ||= []).push(actual);
          }}
        >
          <div style={{ height: 200, overflow: 'auto' }}>{MOUNT[id]()}</div>
        </Profiler>
      )}
    </div>
  );
};

const UpdateBench = ({ id }: { id: string }) => {
  const [n, setN] = useState(0);
  const run = () => {
    window.__bench[`u-${id}`] = [];
    for (let i = 1; i <= 30; i++) flushSync(() => setN((x) => x + 1));
  };
  return (
    <div>
      <button id={`u-${id}`} onClick={run}>
        update {id}
      </button>
      <Profiler
        id={id}
        onRender={(_, phase, actual) => {
          if (phase === 'update') (window.__bench[`u-${id}`] ||= []).push(actual);
        }}
      >
        <div style={{ height: 200, overflow: 'auto' }}>{UPDATE[id](n)}</div>
      </Profiler>
    </div>
  );
};

export const Mount: StoryObj = {
  render: () => (
    <>
      {Object.keys(MOUNT).map((id) => (
        <MountBench key={id} id={id} />
      ))}
    </>
  ),
};
export const Update: StoryObj = {
  render: () => (
    <>
      {Object.keys(UPDATE).map((id) => (
        <UpdateBench key={id} id={id} />
      ))}
    </>
  ),
};

Runner (paste into the console on iframe.html?id=bench-stylememo--mount):

(async () => {
  const w = (t = 250) => new Promise((r) => setTimeout(r, t));
  const res = {};
  for (const id of [...document.querySelectorAll('button[id^=m-]')].map((b) => b.id)) {
    window.__bench[id] = [];
    for (let i = 0; i < 11; i++) {
      document.getElementById(id).click(); await w();
      document.getElementById(id).click(); await w();
    }
    const s = window.__bench[id].slice(1).sort((a, b) => a - b);
    res[id] = +s[Math.floor(s.length / 2)].toFixed(1);
  }
  console.table(res);
})();

For bench-stylememo--update, click each u-* button once and take the median of window.__bench[id].slice(3).

API Changes

None.

Impact Assessment

  • 🔴 Breaking changes
  • 💅 Visual changes
  • 🧪 Test impact
  • 🔧 Hard to integrate

No Kibana staging branch needed I think. The generated CSS and Emotion labels are identical, so class names don't change (no EUI snapshot changes either), and useEuiMemoizedStyles falls back to a default cache when there's no EuiProvider, same as EuiBadge/EuiAccordion today.

Dosant added 2 commits October 6, 2026 15:44
Share generated styles across instances with useEuiMemoizedStyles and
compute EuiCodeBlock line styles once per block instead of once per line.
…styles

Keeps the card image Emotion label unchanged.
@Dosant Dosant added the skip-changelog Use on PRs to skip changelog requirement (Don't delete - used for automation) label Oct 6, 2026
@Dosant
Dosant marked this pull request as ready for review October 6, 2026 14:25
@Dosant
Dosant requested a review from a team as a code owner October 6, 2026 14:25
Copilot AI balanced review requested due to automatic review settings October 6, 2026 14:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Progress size getters still regenerate Emotion styles for every rendered instance.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Memoizes component styles to reduce repeated Emotion generation and color calculations.

Changes:

  • Adds shared per-variant style generators for Token, Progress, and Card.
  • Memoizes theme-dependent component styles.
  • Hoists CodeBlock line-style generation outside its line loop.
File Description
token/​token.tsx Uses memoized fill styles.
token/​token.styles.ts Adds static fill generators.
progress/​progress.tsx Uses memoized progress styles.
progress/​progress.styles.ts Adds determinate variant generators.
code/​utils.tsx Generates line styles once per block.
card/​card.tsx Uses memoized card and text styles.
card/​card.styles.ts Adds padding-specific generators.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +128 to +130
const styles = useEuiMemoizedStyles(
determinate ? euiProgressNativeStyles : euiProgressIndeterminateStyles
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not important. the boost comes from expensive makeHighContrastColor and the native/indeterminate blocks

@infra-vault-gh-plugin-prod

Copy link
Copy Markdown

💚 Build Succeeded

History

@infra-vault-gh-plugin-prod

Copy link
Copy Markdown

💚 Build Succeeded

History

@mgadewoll mgadewoll left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 LGTM. Thanks for the update!

};

// Static per-size generators so `useEuiMemoizedStyles` can share them across instances
export const euiCardStylesByPadding: Record<

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 Non blocking, general thought: We're creating 6 cached style objects that are in large parts identical (only image/icon use paddingAmount). If we wanted to optimize further we could split shared styles and dynamic styles into separate memoized chunks. It reduces duplicate cached styles but adds a bit more complexity in code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mgadewoll, I wanted to avoid changing the generated classes and markup to avoid jumping hurdles with the integration branch and a large EUI PR review :D Do you think it is a wrong approach to optimize for that in this case? I so, we can adjut the code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have to update anything additional in this PR. I just think we might want to ask what the end state of the style memoization should be: Are we fine with duplicated cached styles or do we opt to optimize that as well and is it worth it. That could be a separate update if we want to do it.

Just for transparency: the split wouldn't change any classes afaik, I'm thinking of something like this:

// card.tsx
const baseStyles = useEuiMemoizedStyles(euiCardStyles);
const paddingStyles = useEuiMemoizedStyles(
  euiCardStylesByPadding[paddingSize]
);
const styles = { ...baseStyles, ...paddingStyles };
  
// card.styles.ts
export const euiCardStyles = (euiThemeContext: UseEuiTheme) => {
  const { euiTheme } = euiThemeContext;
  const spacing = euiPaddingSize(euiThemeContext, paddingKey);
  const halfSpacing = euiPaddingSize(euiThemeContext, halfPaddingKey);

  return {
    card: {}
    ...
    // no image or icon styles
  }
}

export const euiCardDynamicStyles = (
  euiThemeContext: UseEuiTheme,
  paddingSize: EuiCardProps['paddingSize']
) => {
  const { euiTheme } = euiThemeContext;
  const paddingAmount = euiPaddingSize(euiThemeContext, paddingSize!);
  const spacing = euiPaddingSize(euiThemeContext, paddingKey);
  const halfSpacing = euiPaddingSize(euiThemeContext, halfPaddingKey);

  return {
    image: {
      euiCard__image: css`
        position: relative;
        overflow: hidden;

        /* Padding based sizing & negative margins */
        ${logicalCSS('width', `calc(100% + (${paddingAmount} * 2))`)}
        ${logicalCSS('left', `-${paddingAmount}`)}
        ${logicalCSS('top', `-${paddingAmount}`)}
        /* Ensure the parent is only as tall as the image */
        ${logicalCSS('margin-bottom', `-${paddingAmount}`)}

        /* Match border radius */
        ${logicalCSS(
          'border-top-left-radius',
          `calc(${euiTheme.border.radius.panel})`
        )}
        ${logicalCSS(
          'border-top-right-radius',
          `calc(${euiTheme.border.radius.panel})`
        )};

        img {
          ${logicalCSS('width', '100%')}/* 4 */
        }
      `,
      transparent: css`
        border-radius: ${euiTheme.border.radius.panel};
      `,
    },

    icon: {
      euiCard__icon: css``,

      withImage: css`
        position: absolute;
        ${logicalCSS('top', '50%')}
        ${logicalCSS('left', '50%')}
        /* Important needed to override current Sass styles on .euiIcon */
        transform: translate(
          -50%,
          calc(-50% + -${paddingAmount})
        ) !important; /* stylelint-disable-line declaration-no-important */
      `,

      layout: {
        vertical: css`
          ${logicalCSS('margin-top', halfSpacing)}
        `,
        horizontal: css`
          ${logicalCSS('margin-right', spacing)}
        `,
      },
    },
  };
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, i see! I guess these would worth it if a component has a lot of variations that are widely used sizes × colors × …

@Dosant
Dosant merged commit 23459da into elastic:main Oct 7, 2026
10 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changelog Use on PRs to skip changelog requirement (Don't delete - used for automation)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Emotion] Memoize EuiToken styles [Emotion] Memoize EuiProgress styles [Emotion] Memoize EuiCodeBlock line styles

3 participants