Skip to content

feat(icons): prevent layout shift by pre-calculating icon size for SSR#882

Open
naporin0624 wants to merge 30 commits intomainfrom
fix/icon-layout-shift
Open

feat(icons): prevent layout shift by pre-calculating icon size for SSR#882
naporin0624 wants to merge 30 commits intomainfrom
fix/icon-layout-shift

Conversation

@naporin0624
Copy link
Copy Markdown
Member

@naporin0624 naporin0624 commented Mar 23, 2026

やったこと

  • @charcoal-ui/iconscalcActualSize 関数と IconSizing union 型を追加し、SSR 環境でもアイコンサイズを事前計算できるようにした
  • @charcoal-ui/icons に SSR 用 CSS (icon.css) を追加し、CSS 変数でカスタム要素の hydration 前にサイズを確保する仕組みを導入
  • @charcoal-ui/reactIcon コンポーネントで、事前計算したサイズを --charcoal-icon-ssr-size CSS 変数と data-charcoal-icon-size 属性として出力するようにした
  • CI の Playwright ブラウザインストールを修正し、バージョン不一致を解消

calcActualSize パラメータ

パラメータ 説明
name string "<size>/<Name>" 形式のアイコン名(例: "24/Add", "Inline/Search")。サイズプレフィックスは Inline(baseSize=16)、正の整数(24, 32 等)のいずれか
scale 1 | 2 | 3 ガイドライン倍率。Inline では 1→16, 2→32(テキストに並ぶアイコンのため 24px ではなく行の高さに合わせた 16/32 の二値。既存実装 080ef02 から scale1 | 2 のみ対応)。24 では 24 * scale。その他のサイズでは無視される
unsafeNonGuidelineScale number 任意倍率。baseSize * scale を返す。ガイドライン外のため unsafe prefix
unsafeNonGuidelineSize number 任意ピクセルサイズ。そのまま返す。最優先

3 つの sizing オプション(scale / unsafeNonGuidelineScale / unsafeNonGuidelineSize)は IconSizing union 型で排他的に制約される。

優先順位: unsafeNonGuidelineSize > unsafeNonGuidelineScale > scale(デフォルト 1)

動作確認環境

  • Vitest ユニットテスト (35 tests for calcActualSize, 54 tests for Icon component)
  • Vitest ブラウザテスト (Chromium, 22 tests)
  • Storybook DOM スナップショット

チェックリスト

  • 追加したコンポーネントが index.ts から再 export されている
  • README やドキュメントに影響があることを確認した

@naporin0624 naporin0624 force-pushed the fix/icon-layout-shift branch 2 times, most recently from b48d874 to bb7e06e Compare March 23, 2026 10:50
Extract icon size calculation into a pure `calcActualSize` function that
can run during SSR without instantiating the Web Component.

- IconSizing union type ensures scale, unsafeNonGuidelineScale, and
  unsafeNonGuidelineSize are mutually exclusive at the type level
- Input validation: throws TypeError for invalid name, zero, negative,
  and Infinity values
- Strengthen render() guard to reject size <= 0
- Mark forceResizedSize/scaledSize getters as @deprecated
- Read pre-computed size from data-charcoal-icon-size via dataset
- Export calcActualSize and IconSizing from @charcoal-ui/icons
- Add packages/icons/css/icon.css with .charcoal-icon class that uses
  --charcoal-icon-ssr-size CSS variable for layout shift prevention
- Use calcActualSize in useMemo to pre-calculate icon dimensions
- Set --charcoal-icon-ssr-size CSS variable and data-charcoal-icon-size
  attribute on pixiv-icon for SSR sizing
- Add .charcoal-icon class for CSS-based size reservation
- Merge user style with SSR CSS variable (prevents {...rest} overwrite)
- Pass through scale and unsafe-non-guideline-scale for backward compat
- Use IconSizing union type for OwnProps (mutually exclusive sizing)
- Rename fixedSize to unsafeNonGuidelineSize for naming consistency
Verify Icon correctly sets data-charcoal-icon-size, --charcoal-icon-ssr-size
CSS variable, charcoal-icon class, scale/unsafe-non-guideline-scale
passthrough, and user style merging via jsdom.
…ut shift

Use vitest browser mode (@vitest/browser + playwright) to verify
pixiv-icon has correct dimensions via getBoundingClientRect in a real
Chromium browser.

Two describe blocks prove no layout shift:
1. Web Component upgrade blocked → CSS alone sizes the element correctly
2. Web Component upgraded → same sizes (no shift on hydration)

Shared test cases cover scale, unsafeNonGuidelineScale,
unsafeNonGuidelineSize, Inline icons, and components using Icon
(HintText, TagItem).
@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 23, 2026

Size Change: +5.04 kB (+1.34%)

Total Size: 381 kB

📦 View Changed
Filename Size Change
packages/icons/dist/index.cjs 11.9 kB +1.95 kB (+19.57%) 🚨
packages/icons/dist/index.d.ts 3.09 kB +593 B (+23.74%) 🚨
packages/icons/dist/index.js 10.6 kB +1.93 kB (+22.11%) 🚨
packages/react/dist/components/Icon/index.d.ts 449 B -49 B (-9.84%) 👏
packages/react/dist/index.cjs 33.9 kB +309 B (+0.92%)
packages/react/dist/index.js 30.7 kB +313 B (+1.03%)
ℹ️ View Unchanged
Filename Size
packages/foundation/dist/index.cjs 801 B
packages/foundation/dist/index.d.ts 3.56 kB
packages/foundation/dist/index.js 703 B
packages/icons-cli/dist/index.js 20.3 kB
packages/react-sandbox/dist/_lib/compat.d.ts 427 B
packages/react-sandbox/dist/_lib/ComponentAbstraction.d.ts 967 B
packages/react-sandbox/dist/components/Carousel/index.d.ts 1.38 kB
packages/react-sandbox/dist/components/CarouselButton/index.d.ts 515 B
packages/react-sandbox/dist/components/Filter/index.d.ts 1.05 kB
packages/react-sandbox/dist/components/HintText/index.d.ts 386 B
packages/react-sandbox/dist/components/icons/Base.d.ts 762 B
packages/react-sandbox/dist/components/icons/DotsIcon.d.ts 226 B
packages/react-sandbox/dist/components/icons/InfoIcon.d.ts 115 B
packages/react-sandbox/dist/components/icons/NextIcon.d.ts 309 B
packages/react-sandbox/dist/components/icons/WedgeIcon.d.ts 345 B
packages/react-sandbox/dist/components/Layout/index.d.ts 2.18 kB
packages/react-sandbox/dist/components/LeftMenu/index.d.ts 438 B
packages/react-sandbox/dist/components/MenuListItem/index.d.ts 1.91 kB
packages/react-sandbox/dist/components/Pager/index.d.ts 557 B
packages/react-sandbox/dist/components/SwitchCheckbox/index.d.ts 340 B
packages/react-sandbox/dist/components/TextEllipsis/helper.d.ts 230 B
packages/react-sandbox/dist/components/TextEllipsis/index.d.ts 381 B
packages/react-sandbox/dist/components/WithIcon/index.d.ts 1.07 kB
packages/react-sandbox/dist/foundation/constants.d.ts 208 B
packages/react-sandbox/dist/foundation/hooks.d.ts 1.04 kB
packages/react-sandbox/dist/foundation/support.d.ts 131 B
packages/react-sandbox/dist/foundation/utils.d.ts 613 B
packages/react-sandbox/dist/hooks/index.d.ts 148 B
packages/react-sandbox/dist/index.cjs 33.2 kB
packages/react-sandbox/dist/index.d.ts 1.38 kB
packages/react-sandbox/dist/index.js 31.1 kB
packages/react-sandbox/dist/misc/storybook-helper.d.ts 343 B
packages/react-sandbox/dist/styled.d.ts 12.3 kB
packages/react/dist/_lib/compat.d.ts 1.19 kB
packages/react/dist/_lib/createDivComponent.d.ts 614 B
packages/react/dist/_lib/index.d.ts 1.02 kB
packages/react/dist/_lib/useClassNames.d.ts 192 B
packages/react/dist/_lib/useForwardedRef.d.ts 169 B
packages/react/dist/components/Button/index.d.ts 766 B
packages/react/dist/components/Button/styledButtonTypeTest.d.d.ts 63 B
packages/react/dist/components/Checkbox/CheckboxInput/index.d.ts 628 B
packages/react/dist/components/Checkbox/CheckboxWithLabel.d.ts 271 B
packages/react/dist/components/Checkbox/index.d.ts 592 B
packages/react/dist/components/Clickable/index.d.ts 681 B
packages/react/dist/components/DropdownSelector/Divider/index.d.ts 133 B
packages/react/dist/components/DropdownSelector/DropdownMenuItem/index.d.ts 441 B
packages/react/dist/components/DropdownSelector/DropdownPopover.d.ts 514 B
packages/react/dist/components/DropdownSelector/index.d.ts 877 B
packages/react/dist/components/DropdownSelector/ListItem/index.d.ts 485 B
packages/react/dist/components/DropdownSelector/MenuItem/index.d.ts 639 B
packages/react/dist/components/DropdownSelector/MenuItem/internals/handleFocusByKeyBoard.d.ts 373 B
packages/react/dist/components/DropdownSelector/MenuItem/internals/useMenuItemHandleKeyDown.d.ts 480 B
packages/react/dist/components/DropdownSelector/MenuItemGroup/index.d.ts 442 B
packages/react/dist/components/DropdownSelector/MenuList/index.d.ts 568 B
packages/react/dist/components/DropdownSelector/MenuList/internals/getValuesRecursive.d.ts 412 B
packages/react/dist/components/DropdownSelector/MenuList/MenuListContext.d.ts 412 B
packages/react/dist/components/DropdownSelector/Popover/index.d.ts 1.03 kB
packages/react/dist/components/DropdownSelector/Popover/usePreventScroll.d.ts 159 B
packages/react/dist/components/DropdownSelector/utils/findPreviewRecursive.d.ts 411 B
packages/react/dist/components/FieldLabel/index.d.ts 492 B
packages/react/dist/components/HintText/index.d.ts 382 B
packages/react/dist/components/IconButton/index.d.ts 639 B
packages/react/dist/components/LoadingSpinner/index.d.ts 678 B
packages/react/dist/components/Modal/Dialog/index.d.ts 373 B
packages/react/dist/components/Modal/index.d.ts 2.13 kB
packages/react/dist/components/Modal/ModalBackgroundContext.d.ts 231 B
packages/react/dist/components/Modal/ModalPlumbing.d.ts 1.75 kB
packages/react/dist/components/Modal/useCustomModalOverlay.d.ts 797 B
packages/react/dist/components/MultiSelect/context.d.ts 394 B
packages/react/dist/components/MultiSelect/index.d.ts 1.25 kB
packages/react/dist/components/Pagination/helper.d.ts 238 B
packages/react/dist/components/Pagination/index.d.ts 2.01 kB
packages/react/dist/components/Pagination/PaginationContext.d.ts 881 B
packages/react/dist/components/Radio/index.d.ts 482 B
packages/react/dist/components/Radio/RadioGroup/index.d.ts 733 B
packages/react/dist/components/Radio/RadioGroupContext.d.ts 339 B
packages/react/dist/components/Radio/RadioInput/index.d.ts 584 B
packages/react/dist/components/SegmentedControl/index.d.ts 836 B
packages/react/dist/components/SegmentedControl/RadioGroupContext.d.ts 362 B
packages/react/dist/components/Switch/index.d.ts 452 B
packages/react/dist/components/Switch/SwitchInput/index.d.ts 482 B
packages/react/dist/components/Switch/SwitchWithLabel.d.ts 272 B
packages/react/dist/components/TagItem/index.d.ts 716 B
packages/react/dist/components/TextArea/index.d.ts 1.12 kB
packages/react/dist/components/TextEllipsis/helper.d.ts 350 B
packages/react/dist/components/TextEllipsis/index.d.ts 924 B
packages/react/dist/components/TextField/AssistiveText/index.d.ts 592 B
packages/react/dist/components/TextField/index.d.ts 1.25 kB
packages/react/dist/components/TextField/useFocusWithClick.d.ts 256 B
packages/react/dist/core/CharcoalProvider.d.ts 270 B
packages/react/dist/core/OverlayProvider.d.ts 101 B
packages/react/dist/core/SetThemeScript.d.ts 890 B
packages/react/dist/core/SSRProvider.d.ts 335 B
packages/react/dist/core/themeHelper.d.ts 2.05 kB
packages/react/dist/index.d.ts 2.67 kB
packages/styled/dist/addThemeUtils.story.d.ts 330 B
packages/styled/dist/builders/border.d.ts 685 B
packages/styled/dist/builders/borderRadius.d.ts 440 B
packages/styled/dist/builders/colors.d.ts 1.28 kB
packages/styled/dist/builders/elementEffect.d.ts 533 B
packages/styled/dist/builders/o.d.ts 5.91 kB
packages/styled/dist/builders/outline.d.ts 638 B
packages/styled/dist/builders/size.d.ts 1.19 kB
packages/styled/dist/builders/spacing.d.ts 1.12 kB
packages/styled/dist/builders/transition.d.ts 287 B
packages/styled/dist/builders/typography.d.ts 624 B
packages/styled/dist/defineThemeVariables.test.d.ts 66 B
packages/styled/dist/factories/lib.d.ts 3.96 kB
packages/styled/dist/index.cjs 11.8 kB
packages/styled/dist/index.d.ts 6.9 kB
packages/styled/dist/index.js 10.3 kB
packages/styled/dist/index.test.d.ts 204 B
packages/styled/dist/internals/index.d.ts 1.6 kB
packages/styled/dist/storyHelper.d.ts 386 B
packages/styled/dist/styles/assertiveRingCss.d.ts 141 B
packages/styled/dist/styles/disabledCss.d.ts 131 B
packages/styled/dist/styles/focusVisibleFocusRingCss.d.ts 318 B
packages/styled/dist/TokenInjector.d.ts 533 B
packages/styled/dist/util.d.ts 4.24 kB
packages/styled/dist/utils/addThemeUtils.d.ts 383 B
packages/styled/dist/utils/CharcoalStyledTheme.d.ts 1.43 kB
packages/styled/dist/utils/gap.d.ts 473 B
packages/styled/dist/utils/helpers/pxIfNum.d.ts 99 B
packages/styled/dist/utils/helpers/SpacingType.d.ts 137 B
packages/styled/dist/utils/margin.d.ts 905 B
packages/styled/dist/utils/padding.d.ts 935 B
packages/styled/dist/utils/typographyCss.d.ts 306 B
packages/tailwind-config/dist/index.d.ts 977 B
packages/tailwind-config/dist/index.js 17.8 kB
packages/tailwind-diff/dist/commands/check.d.ts 296 B
packages/tailwind-diff/dist/commands/check.js 4.35 kB
packages/tailwind-diff/dist/commands/dump.d.ts 185 B
packages/tailwind-diff/dist/commands/dump.js 1.24 kB
packages/tailwind-diff/dist/defer.d.ts 164 B
packages/tailwind-diff/dist/defer.js 762 B
packages/tailwind-diff/dist/index.d.ts 46 B
packages/tailwind-diff/dist/index.js 1.84 kB
packages/tailwind-diff/dist/packageManager.d.ts 323 B
packages/tailwind-diff/dist/packageManager.js 1.81 kB
packages/tailwind-diff/dist/style.d.ts 436 B
packages/tailwind-diff/dist/style.js 2.6 kB
packages/tailwind-diff/dist/withPackages.d.ts 464 B
packages/tailwind-diff/dist/withPackages.js 3.34 kB
packages/theme/dist/index.cjs 3.27 kB
packages/theme/dist/index.d.ts 3.72 kB
packages/theme/dist/index.js 3.17 kB
packages/theme/dist/unstable-css/_variables_dark.css.d.ts 26 B
packages/theme/dist/unstable-css/_variables_light.css.d.ts 26 B
packages/theme/dist/unstable-token-object/index.cjs 4.28 kB
packages/theme/dist/unstable-token-object/index.d.ts 1.63 kB
packages/theme/dist/unstable-token-object/index.js 3.04 kB
packages/token-cli/dist/index.js 6.58 kB
packages/utils/dist/index.cjs 2.6 kB
packages/utils/dist/index.d.ts 3.27 kB
packages/utils/dist/index.js 2.38 kB

compressed-size-action

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 23, 2026

Visit the preview URL for this PR (updated for commit c077202):

https://pixiv-charcoal-web--pr882-fix-icon-layout-shif-8yx13ehg.web.app

(expires Mon, 13 Apr 2026 13:21:48 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: 314b26d3adca98a761c7e4d9922ebb206ff024a0

…ne-scale

parseInt('3.75', 10) truncates to 3, but unsafe-non-guideline-scale
accepts decimal values (e.g. '3.75'). Use Number() to preserve the
fractional part. This fixes the image snapshot mismatch for the
WithUnsafe story.
vite:import-analysis cannot resolve the @charcoal-ui/icons-css alias
in CI. Use a virtual module plugin to inline icon.css content instead.
Define CSS string directly in the test file. The icon.css content is
small (4 lines) and this avoids import resolution issues in CI where
the @charcoal-ui/icons-css alias cannot be resolved by vite.
@naporin0624 naporin0624 changed the title Fix/icon layout shift feat(icons): prevent layout shift by pre-calculating icon size for SSR Mar 30, 2026
…ser test

The previous approach monkey-patched customElements.define to block
pixiv-icon registration, but Vite pre-bundles dependencies so the
side effect runs before the patch. Test CSS sizing mechanism directly
with plain HTML elements instead.
Root `vitest` runs without config and picks up all *.test.* files.
Browser tests (*.browser.test.*) require a real browser via Playwright,
so they fail in the default jsdom/node environment.

- Exclude *.browser.test.* from `pnpm test` via --exclude flag
- Add `test:browser` script that runs browser tests separately
- Install Playwright chromium in CI with actions/cache
Two Playwright versions (1.51.1 and 1.58.2) coexisted in the lockfile,
causing CI to install revision 1161 browsers while vitest required
revision 1208. Align all Playwright dependencies to ^1.58.2 via
specifier updates and pnpm overrides, and simplify the CI install step.
…alSize

Eliminate nested switch statements by extracting size calculation logic
into dedicated functions for better readability.
…rate module

Move calcActualSize, IconSizing, parseIconName, inlineSize,
guidelineSize24, and isPositiveFinite into src/calcActualSize.ts.
Move DOM attribute parsing into inline getters on the calcActualSize
argument object instead of separate local variables.
@naporin0624 naporin0624 added the image snapshots update Pull requests that update image snapshots label Mar 30, 2026
@naporin0624 naporin0624 marked this pull request as ready for review March 30, 2026 12:14
@mimokmt mimokmt self-requested a review April 3, 2026 07:31
Add pre-definition size rules based on icon name prefix and scale
attributes. Also fix snapshot test comparison and add vitest forks pool.
Replace `cd packages/react && npx vitest run --config` with
vitest workspace configuration and `--project browser` filtering.
…ne styles

React Icon and PixivIcon WC now inject --charcoal-icon-unsafe-scale as
an inline CSS variable, enabling the :not(:defined) CSS to correctly
reserve space for unsafe-non-guideline-scale before JS bootstrap.
Also fixes MultiSelect using dashed prop form which bypassed calcActualSize.
Replace outdated manual CSS example with documentation for
@charcoal-ui/icons/css/icon.css and its two mechanisms:
.charcoal-icon class (React) and :not(:defined) selector (vanilla HTML).
…riables

BREAKING CHANGE: Remove unsafe-non-guideline-scale HTML attribute from
PixivIcon web component. Sizing is now determined by CSS variables
(--charcoal-icon-unsafe-scale, --charcoal-icon-ssr-size) and data
attributes (data-charcoal-icon-size) instead.

- Remove unsafe-non-guideline-scale from observedAttributes and Props
- Remove deprecated forceResizedSize getter
- Extend props getter to read from CSS variables and data attributes
- React Icon always sets data-charcoal-icon-size with calculated size
- Remove &[unsafe-non-guideline-scale] CSS block from icon.css
…proach

Replace unsafe-non-guideline-scale attribute examples with
data-charcoal-icon-size and --charcoal-icon-ssr-size usage.
@naporin0624 naporin0624 force-pushed the fix/icon-layout-shift branch from e67c49f to 57ed0e8 Compare April 6, 2026 12:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

image snapshots update Pull requests that update image snapshots patch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants