From dbefdee0eff61826a99f42e56d5b088ea794795c Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Fri, 29 May 2026 18:38:17 +0200 Subject: [PATCH 1/5] fix(icon): seed cached icon synchronously to avoid mount flash Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/components/icon/Icon.component.tsx | 32 ++++++++++++++-------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/lib/components/icon/Icon.component.tsx b/src/lib/components/icon/Icon.component.tsx index 9371f1b776..73ce6765b9 100644 --- a/src/lib/components/icon/Icon.component.tsx +++ b/src/lib/components/icon/Icon.component.tsx @@ -137,21 +137,32 @@ function NonWrappedIcon({ const iconInfo = iconTable[name] || customIcons[name]; if (!iconInfo) throw new Error(`${name}: is not a valid icon.`); - // Loaded fortawesome icon if not a custom icon - const [icon, setIcon] = useState(); + const [fontAwesomeType, iconClass] = customIcons[name] + ? [] + : (() => { + const [type, cls] = iconInfo.split(' '); + return [ + type === 'far' ? 'free-regular-svg-icons' : 'free-solid-svg-icons', + cls, + ]; + })(); + const cacheKey = iconClass ? `${fontAwesomeType}/${iconClass}` : undefined; + + // Seed from the module-level cache synchronously so an already-loaded icon paints on the + // first render. Reading the cache only inside the effect (which runs after paint) makes a + // freshly-mounted icon — e.g. a virtualized table row scrolling into view — render the empty + // zero-width fallback for a frame, shifting neighbouring content before the real glyph appears. + const [icon, setIcon] = useState(() => + cacheKey ? iconCache[cacheKey] : undefined, + ); useEffect(() => { - if (customIcons[name]) { + if (!cacheKey || !iconClass) { return; } - - const [iconType, iconClass] = iconInfo.split(' '); - const fontAwesomeType = - iconType === 'far' ? 'free-regular-svg-icons' : 'free-solid-svg-icons'; - const cacheKey = `${fontAwesomeType}/${iconClass}`; if (iconCache[cacheKey]) { setIcon(iconCache[cacheKey]); - return () => setIcon(undefined); + return; } // Handle FontAwesome icons with dynamic import @@ -164,8 +175,7 @@ function NonWrappedIcon({ }).catch((err) => { console.warn(`Icon ${iconClass} could not be loaded:`, err.message); }); - return () => setIcon(undefined); - }, [name, iconInfo]); + }, [cacheKey, fontAwesomeType, iconClass]); // Icons are decorative by default (aria-hidden: true) // If ariaLabel is provided, the icon is meaningful (aria-hidden: false) From 592056f81c2affa832d0debb71d1b37b1c56e9c2 Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Mon, 1 Jun 2026 12:02:02 +0200 Subject: [PATCH 2/5] fix(icon): reserve icon box in delayed fallback to avoid layout shift Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/components/icon/Icon.component.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/lib/components/icon/Icon.component.tsx b/src/lib/components/icon/Icon.component.tsx index 73ce6765b9..05c60c9029 100644 --- a/src/lib/components/icon/Icon.component.tsx +++ b/src/lib/components/icon/Icon.component.tsx @@ -77,9 +77,10 @@ type Props = { }; const DelayedFallback = ({ + size, children, ...rest -}: PropsWithChildren>) => { +}: PropsWithChildren, 'size'> & { size?: SizeProp }>) => { const [show, setShow] = useState(false); useEffect(() => { let timeout = setTimeout(() => setShow(true), 300); @@ -88,7 +89,20 @@ const DelayedFallback = ({ }; }, []); - return {show && children}; + // Reserve the icon's box so a cold-loading icon doesn't paint zero-width and shift + // neighbouring content. svg-inline--fa gives height:1em and the icon baseline, and + // fa-${size} scales font-size so 1em tracks the requested size. Width has no intrinsic + // SVG to derive from, so set a 1em square — matches the glyph height exactly and + // approximates its (variable) width without altering how loaded icons render. + return ( + + {show && children} + + ); }; export const IconWrapper = styled.div<{ size: SizeProp }>` @@ -185,7 +199,7 @@ function NonWrappedIcon({ if (!icon && !customIcons[name]) { return ( - + ); From 88912d28e1a19b8b7fcff18788b2f5bdd3676f22 Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Mon, 1 Jun 2026 12:34:31 +0200 Subject: [PATCH 3/5] fix(icon): scale fallback spinner with size instead of fixed px Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/components/icon/Icon.component.tsx | 25 +++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/lib/components/icon/Icon.component.tsx b/src/lib/components/icon/Icon.component.tsx index 05c60c9029..ed25b9ebd1 100644 --- a/src/lib/components/icon/Icon.component.tsx +++ b/src/lib/components/icon/Icon.component.tsx @@ -9,7 +9,7 @@ import { } from 'react'; import styled, { css } from 'styled-components'; import type { CoreUITheme } from '../../style/theme'; -import { Loader } from '../loader/Loader.component'; +import { LoaderIcon } from '../../icons/scality-loading'; import { Bucket, Buckets, RemoteGroup, RemoteUser } from './CustomsIcons'; import { iconTable } from './iconTable'; @@ -76,6 +76,18 @@ type Props = { title?: string; }; +// The spinner shown while a cold icon loads. Sized in em so it scales with the +// fa-${size} font-size of the fallback box, matching the glyph it stands in for — +// unlike , whose container forces a fixed px svg and resets font-size. +const FallbackSpinner = styled.span` + display: inline-flex; + svg { + width: 1em; + height: 1em; + fill: currentColor; + } +`; + const DelayedFallback = ({ size, children, @@ -100,7 +112,12 @@ const DelayedFallback = ({ className={`svg-inline--fa${size ? ` fa-${size}` : ''}`} style={{ width: '1em' }} > - {show && children} + {show && + (children ?? ( + + + + ))} ); }; @@ -199,9 +216,7 @@ function NonWrappedIcon({ if (!icon && !customIcons[name]) { return ( - - - + ); } From b1df524f22aa67ad88dd6de76c3276230e2200d0 Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Mon, 1 Jun 2026 14:41:24 +0200 Subject: [PATCH 4/5] fix(icon): keep fallback spinner purple (Loader default color) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/components/icon/Icon.component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/icon/Icon.component.tsx b/src/lib/components/icon/Icon.component.tsx index ed25b9ebd1..df718cb784 100644 --- a/src/lib/components/icon/Icon.component.tsx +++ b/src/lib/components/icon/Icon.component.tsx @@ -84,7 +84,7 @@ const FallbackSpinner = styled.span` svg { width: 1em; height: 1em; - fill: currentColor; + fill: #a14fbf; } `; From e270b7919c424ba8f7d7980e667540b51fdbb227 Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Mon, 1 Jun 2026 17:35:06 +0200 Subject: [PATCH 5/5] fix(icon): clear stale glyph on name change; extract spinner color constant Addresses review feedback: - When `name` changes on a mounted Icon to an icon that isn't cached yet, reset `icon` to undefined so the reserved fallback shows instead of the previous glyph while the dynamic import resolves. - Extract the fallback spinner color into a named LOADER_SPINNER_COLOR constant (mirrors the default) instead of an inline magic hex. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/components/icon/Icon.component.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib/components/icon/Icon.component.tsx b/src/lib/components/icon/Icon.component.tsx index df718cb784..a2dcfdc584 100644 --- a/src/lib/components/icon/Icon.component.tsx +++ b/src/lib/components/icon/Icon.component.tsx @@ -76,6 +76,10 @@ type Props = { title?: string; }; +// Mirrors the default spinner color (see Loader.component.tsx) so the +// fallback looks identical to a real Loader while the icon resolves. +const LOADER_SPINNER_COLOR = '#A14FBF'; + // The spinner shown while a cold icon loads. Sized in em so it scales with the // fa-${size} font-size of the fallback box, matching the glyph it stands in for — // unlike , whose container forces a fixed px svg and resets font-size. @@ -84,7 +88,7 @@ const FallbackSpinner = styled.span` svg { width: 1em; height: 1em; - fill: #a14fbf; + fill: ${LOADER_SPINNER_COLOR}; } `; @@ -196,6 +200,10 @@ function NonWrappedIcon({ return; } + // The name changed to an icon we haven't loaded yet: drop the previous glyph so the + // reserved fallback shows instead of a stale icon while the dynamic import resolves. + setIcon(undefined); + // Handle FontAwesome icons with dynamic import import( /* webpackExclude: /import\.macro\.js$/ */