Skip to content

perf(core): remove react-transition-group, use CSS data-state machines#3369

Open
orrgottlieb wants to merge 3 commits into
masterfrom
perf/remove-react-transition-group
Open

perf(core): remove react-transition-group, use CSS data-state machines#3369
orrgottlieb wants to merge 3 commits into
masterfrom
perf/remove-react-transition-group

Conversation

@orrgottlieb

@orrgottlieb orrgottlieb commented May 21, 2026

Copy link
Copy Markdown
Contributor

User description

Summary

  • Replaces react-transition-group <CSSTransition> with explicit data-state state machines (entering/entered/exiting/exited) in Counter, Modal, Toast, and MultiStepIndicator/StepIndicator.
  • State transitions are driven by useState + setTimeout, with timing constants sourced from the corresponding SCSS animation durations.
  • Trims the rendered tree by one wrapper per animated component and removes a runtime dependency.

Why

react-transition-group is a relatively heavy runtime dependency for what these components actually need (a small finite state machine that toggles a CSS class). Inlining the state transitions:

  • avoids the <Transition> wrapper render cost,
  • eliminates a 7-year-old peer surface area,
  • aligns with the rest of the design system which already drives motion via CSS tokens.

Notes

  • Toast snapshot file has been regenerated to reflect the simpler render output.
  • Timing constants per component:
    • COUNTER_SWAP_TRANSITION_MS = 150
    • MODAL_ENTER_MS = 250 / MODAL_EXIT_MS = 150
    • TOAST_TRANSITION_MS = 400
  • No public API changes.

Test plan

  • CI vitest suites for Counter / Modal / Toast / MultiStepIndicator pass
  • Storybook visual regression for the four components
  • Manual smoke: open/close Modal, animate Toast in/out, swap Counter value, advance MultiStepIndicator
  • Verify no leftover react-transition-group imports in packages/core/src/components

🤖 Generated with Claude Code


PR Type

Enhancement


Description

  • Remove react-transition-group dependency, replace with CSS state machines

    • Counter, Modal, Toast, MultiStepIndicator/StepIndicator use useState + setTimeout
    • Timing constants sourced from SCSS animation durations
  • Add direction-aware Counter animations

    • Upward swap on increase, downward on decrease
    • Uses @keyframes for reliable entrance animations
  • Simplify rendered tree by eliminating <Transition> wrapper components

  • Update Modal focus lock test to use fake timers for exit transition completion


Diagram Walkthrough

flowchart LR
  A["react-transition-group<br/>CSSTransition/SwitchTransition"] -->|"Replace with"| B["useState + setTimeout<br/>State Machines"]
  B -->|"Apply to"| C["Counter"]
  B -->|"Apply to"| D["Modal"]
  B -->|"Apply to"| E["Toast"]
  B -->|"Apply to"| F["StepIndicator"]
  C -->|"Add direction"| G["Directional Animations<br/>Up/Down"]
  B -->|"Reduce"| H["Rendered Tree Size"]
Loading

File Walkthrough

Relevant files
Enhancement
Counter.module.scss
Add directional keyframe animations for counter swap         

packages/core/src/components/Counter/Counter.module.scss

  • Replace transition-based fade classes with @keyframes animations
  • Add four directional keyframes: counterEnterFromBottom,
    counterEnterFromTop, counterExitToTop, counterExitToBottom
  • Create direction-aware animation classes: fadeEnterUp, fadeEnterDown,
    fadeExitUp, fadeExitDown
  • Use animation property with forwards fill-mode instead of transition
+53/-16 
Counter.tsx
Replace react-transition-group with CSS state machine       

packages/core/src/components/Counter/Counter.tsx

  • Remove react-transition-group imports (CSSTransition,
    SwitchTransition)
  • Add COUNTER_SWAP_TRANSITION_MS = 150 constant matching SCSS duration
  • Implement CSS state machine with displayedText, swapStage, and
    direction state
  • Replace + wrapper with simple element
  • Add three useEffect hooks to manage exiting → entering state
    transitions
  • Apply animation classes based on swap stage and direction
+59/-24 
Modal.tsx
Replace react-transition-group with CSS state machine       

packages/core/src/components/Modal/Modal/Modal.tsx

  • Remove react-transition-group import (CSSTransition)
  • Add useEffect to imports
  • Add MODAL_ENTER_MS = 250 and MODAL_EXIT_MS = 150 constants
  • Implement CSS state machine with transitionStage state
    (entering/entered/exiting/exited)
  • Replace wrapper with conditional rendering based on transition stage
  • Add useEffect hook managing enter/exit state transitions
  • Apply transition classes to container div based on stage
  • Return null when not showing and fully exited
+90/-58 
StepIndicator.tsx
Replace react-transition-group with CSS state machine       

packages/core/src/components/MultiStepIndicator/components/StepIndicator/StepIndicator.tsx

  • Remove react-transition-group imports (CSSTransition,
    SwitchTransition)
  • Add STEP_INDICATOR_SWAP_TRANSITION_MS = 150 constant
  • Implement CSS state machine with displayedStatus, swapStage state
  • Replace + wrapper with simple element
  • Add three useEffect hooks managing exiting → entering state
    transitions
  • Apply swap animation classes based on transition stage
  • Pass displayedStatus to StepCircleDisplay instead of current status
+47/-26 
Toast.tsx
Replace react-transition-group with CSS state machine       

packages/core/src/components/Toast/Toast.tsx

  • Remove react-transition-group import (CSSTransition)
  • Add useState to React imports
  • Add TOAST_TRANSITION_MS = 400 constant matching original timeout
  • Implement CSS state machine with transitionStage state
    (entering/entered/exiting/exited)
  • Replace wrapper with direct element
  • Add useEffect hook managing enter/exit state transitions
  • Apply transition classes to Text element based on stage
  • Return null when not open and fully exited
+73/-46 
Tests
Modal.test.tsx
Update modal test for CSS state machine transitions           

packages/core/src/components/Modal/Modal/tests/Modal.test.tsx

  • Remove react-transition-group mock that was bypassing transitions
  • Import act from @testing-library/react instead of relying on React
    export
  • Add vi.useFakeTimers() to focus lock release test to allow exit
    transition to complete
  • Wrap timer advancement in act() call
  • Add vi.useRealTimers() cleanup after test
+7/-16   
Toast.snapshot.test.tsx
Remove transition mock and update snapshots                           

packages/core/src/components/Toast/tests/Toast.snapshot.test.tsx

  • Remove react-transition-group mock that was bypassing transitions
  • Remove vi import since mocking is no longer needed
  • Update snapshot test for hideIcon case to include open prop for
    consistency
  • Snapshots now reflect simpler render tree without wrapper
+8/-13   

Replace react-transition-group <CSSTransition> usage with explicit
data-state machines (entering/entered/exiting/exited) driven by
useState + setTimeout, with timing constants sourced from SCSS.

Affected components: Counter, Modal, Toast, MultiStepIndicator/StepIndicator.

Reduces runtime dependency on react-transition-group and trims the
rendered tree (no Transition wrapper). Snapshot for Toast is regenerated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@orrgottlieb orrgottlieb requested a review from a team as a code owner May 21, 2026 23:13
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (1)

Grey Divider


Action required

1. Toast root missing data-vibe 📘 Rule violation ⚙ Maintainability
Description
The Toast component’s root rendered element (Text/div) does not include the required
data-vibe attribute. This breaks standardized component identification used by design-system
tooling.
Code

packages/core/src/components/Toast/Toast.tsx[R212-224]

Evidence
PR Compliance ID 1 requires each component’s root DOM element to include [data-vibe]. In
Toast.tsx, the returned root element is ` and it does not set data-vibe` at all.

CLAUDE.md: All Components Must Include a Root [data-vibe] Attribute: CLAUDE.md: All Components Must Include a Root [data-vibe] Attribute: CLAUDE.md: All Components Must Include a Root [data-vibe] Attribute: CLAUDE.md: All Components Must Include a Root [data-vibe] Attribute: CLAUDE.md: All Components Must Include a Root [data-vibe] Attribute: CLAUDE.md: All Components Must Include a Root [data-vibe] Attribute: CLAUDE.md: All Components Must Include a Root [data-vibe] Attribute: CLAUDE.md: All Components Must Include a Root [data-vibe] Attribute
packages/core/src/components/Toast/Toast.tsx[211-225]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Toast` renders a root `div` (via `Text element="div"`) without the required `data-vibe` attribute.
## Issue Context
Compliance requires every component to expose a standardized `data-vibe` attribute on its root DOM element for consistent identification and tooling.
## Fix Focus Areas
- packages/core/src/components/Toast/Toast.tsx[211-225]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Modal reopen stuck 🐞 Bug ≡ Correctness
Description
In Modal and Toast, if show/open flips back to true while transitionStage is exiting, the
exit timer cleanup can clear the only scheduled transition to exited and there is no logic to move
from exiting back to entering, leaving the component stuck mounted with exit/hidden styles
despite being open. Additionally, when opening from exited, each component can render for one
frame without the required enter “FROM” styles/classes, causing a visible flash/jump before the
enter animation applies.
Code

packages/core/src/components/Modal/Modal/Modal.tsx[R140-168]

Evidence
The transition effect only accounts for opening when the stage is exited/entering and closing
when the stage is entered/entering/exiting, but it does not handle the case where
show/open becomes true while transitionStage==='exiting'; when that prop change happens, the
effect cleanup can cancel the in-flight exit timer, removing the only path to reach exited and
leaving the state machine with no route back to entering. The styling further shows why there is a
visual artifact on open: Modal’s SCSS requires the .containerEnter FROM state “to prevent flash on
mount”, and Toast’s SCSS has a base .toast transform that is already at the final position while
slideIn begins at translate(-50%, -100px), so if the enter class (e.g., .enterActive) is
applied only after an initial paint where transitionStage==='exited', a jump/flash can occur; the
Storybook pattern of keeping Toast mounted and controlling it via open matches this problematic
flow.

packages/core/src/components/Modal/Modal/Modal.tsx[137-174]
packages/core/src/components/Modal/Modal/Modal.module.scss[116-174]
packages/docs/src/pages/components/Modal/ModalBasicLayout.stories.tsx[66-87]
packages/core/src/components/Toast/Toast.tsx[178-209]
packages/core/src/components/Toast/Toast.module.scss[3-130]
packages/docs/src/pages/components/Toast/Toast.stories.tsx[154-217]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Modal and Toast have a transition state machine edge case and a first-open paint glitch.
1) If `show`/`open` becomes `true` while `transitionStage === "exiting"`, the exit timer cleanup may cancel the only scheduled transition to `exited`, and there is no explicit branch to move from `exiting` back to `entering`, leaving the component stuck mounted in exit/hidden styles while it is supposed to be open.
2) When toggling from closed to open (`exited` → open), each component can render one frame in `transitionStage === "exited"` without the required enter “FROM” classes/styles (Modal: missing `.containerEnter`; Toast: missing `.enterActive`/FROM state), causing a visible flash/jump before the enter animation starts.
## Issue Context
- The current effect logic handles opening only from `exited`/`entering` and closing from `entered`/`entering`/`exiting`, but does not handle `open/show === true` while already `exiting`.
- Cleanup functions can clear timers when props change, which can cancel an in-flight exit transition and leave the state machine without a deterministic way to complete or reverse.
- Modal SCSS relies on `.containerEnter` as the FROM state to prevent a mount flash, so it needs to be present before the first paint on open.
- Toast styling uses keyframes (`slideIn`/`slideOut`) and a base `.toast` transform that is already at the final position, so applying the enter class only after a paint can create a noticeable jump; this is especially relevant in controlled usage where Toast remains mounted and is toggled via `open`.
## Fix Focus Areas
- packages/core/src/components/Modal/Modal/Modal.tsx[137-174]
- packages/core/src/components/Toast/Toast.tsx[178-223]
- packages/core/src/components/Toast/Toast.module.scss[3-130]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Modal overlay fade skipped 🐞 Bug ≡ Correctness ⭐ New
Description
Modal applies both containerEnter and containerEnterActive in the same render for the entering
stage, so .overlay computes to opacity: 1 immediately and the intended opacity 0 -> 1
transition never occurs. This changes the visual behavior of modal open (overlay appears instantly
instead of fading).
Code

packages/core/src/components/Modal/Modal/Modal.tsx[R170-174]

Evidence
The SCSS defines .containerEnter .overlay as opacity: 0, while .containerEnterActive .overlay
sets opacity: 1 and the transition; applying both classes simultaneously means the later rule wins
and there is no initial opacity: 0 state to transition from. The TSX currently applies both
classes whenever transitionStage is entering.

packages/core/src/components/Modal/Modal/Modal.tsx[137-175]
packages/core/src/components/Modal/Modal/Modal.module.scss[116-155]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`Modal` sets both `styles.containerEnter` and `styles.containerEnterActive` whenever `transitionStage === "entering"`. In `Modal.module.scss`, these two classes set conflicting overlay opacity values (`0` vs `1` + transition), so applying both at once results in `opacity: 1` from the start and the overlay fade-in transition never runs.

### Issue Context
This is a behavioral change versus `CSSTransition`, which applies `enter` first and then (on the next tick) `enterActive` to trigger the transition.

### Fix Focus Areas
- packages/core/src/components/Modal/Modal/Modal.tsx[137-175]
- packages/core/src/components/Modal/Modal/Modal.module.scss[116-155]

### Suggested fix approach
1. Introduce a two-phase enter state, e.g. `"enter" | "enter-active" | "entered" | ...`.
2. When `show` becomes true:
  - synchronously set stage to `"enter"` (apply `containerEnter` only)
  - then in `requestAnimationFrame` (or a `setTimeout(0)` / `useLayoutEffect`) move to `"enter-active"` (apply `containerEnterActive`)
  - after `MODAL_ENTER_MS`, move to `"entered"` (apply neither)
3. Keep exit as-is (it already uses only `containerExitActive`).

This preserves the “FROM then TO” semantics required for CSS transitions like the overlay fade.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Counter direction desync 🐞 Bug ≡ Correctness ⭐ New
Description
Counter computes direction only when starting an exit (swapStage !== "exiting"), but the exit
timeout swaps displayedText to the latest countText; if count changes again during the exit
window, the component can animate the new value using the wrong direction. This produces incorrect
up/down motion for rapid updates (e.g., increase then decrease within 150ms).
Code

packages/core/src/components/Counter/Counter.tsx[R139-160]

Evidence
direction is only set when swapStage !== "exiting", but the exit timer uses countText from the
timer effect (which is re-scheduled on countText changes). That allows the swapped-in
displayedText to reflect newer countText updates without recomputing direction.

packages/core/src/components/Counter/Counter.tsx[130-160]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`direction` is frozen when the swap enters `exiting`, but `setDisplayedText(countText)` uses the current `countText` when the exit timer fires. If `count` changes again while still `exiting`, the swapped-in text can represent a later update while `direction` still reflects the earlier update.

### Issue Context
This is introduced by moving from transition-end driven `SwitchTransition` semantics to a timer-driven state machine.

### Fix Focus Areas
- packages/core/src/components/Counter/Counter.tsx[130-166]

### Suggested fix approach
Implement queuing so each animation corresponds to a single `(fromCount -> toCount)` change:
1. Add a `queuedCountTextRef` (or state) that always tracks the latest `countText`.
2. When `countText` changes:
  - if `swapStage === "idle"`, start an exit and compute direction from `count` vs a stored `prevCount`.
  - if `swapStage !== "idle"`, only update the queued value (do NOT change `direction` / stages mid-animation).
3. When the animation returns to `idle`, if `displayedText !== queuedCountText`, start another cycle.

This prevents mixing a later value with an earlier direction and keeps direction consistent with the actual value transition being animated.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Toast width recalc no-op 🐞 Bug ≡ Correctness
Description
Toast’s width-recalculation effect is gated on ref.current, but the rendered root element is
attached to nodeRef, so ref.current stays null and recalculateElementWidth never runs
(breaking the intended width transition behavior).
Code

packages/core/src/components/Toast/Toast.tsx[R212-222]

Evidence
The Toast root element is rendered with ref={nodeRef}, but the width recalculation effect checks
ref.current, which is never assigned to any element, so the effect’s body never runs.

packages/core/src/components/Toast/Toast.tsx[90-91]
packages/core/src/components/Toast/Toast.tsx[172-176]
packages/core/src/components/Toast/Toast.tsx[211-225]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Toast` has two refs: `ref` (used by the width-recalculation effect) and `nodeRef` (attached to the rendered `<Text>`). Because `ref` is never attached to any DOM node, `ref.current` is always null and the width-recalculation logic never executes.
### Issue Context
The effect is intended to force width transitions when content changes (see the inline comment referencing transitioning to/from `auto`). Right now it can’t work because it never receives a real element.
### Fix Focus Areas
- packages/core/src/components/Toast/Toast.tsx[90-91]
- packages/core/src/components/Toast/Toast.tsx[172-176]
- packages/core/src/components/Toast/Toast.tsx[211-225]
### Suggested fix
- Remove the extra `ref` and use `nodeRef` for both purposes (recommended), e.g. call `recalculateElementWidth(nodeRef.current)`.
- Ensure the type matches: `useRef<HTMLElement | null>(null)` (or `HTMLDivElement`) and keep `element="div"`.
- Alternatively, attach `ref={ref}` to the root element and stop using `nodeRef` unless it’s needed elsewhere.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
6. Counter enter state skipped ✓ Resolved 🐞 Bug ≡ Correctness
Description
Counter and StepIndicator apply their enter FROM and enterActive/TO classes simultaneously
(fadeEnter+fadeEnterActive, swapEnter+swapEnterActive), so the CSS FROM state (opacity:0; transform:
translateY(15px)) is overridden and never takes effect, altering the intended enter/swap motion.
This differs from the previous SwitchTransition semantics where the entering node began in the enter
FROM class before transitioning to the active state.
Code

packages/core/src/components/Counter/Counter.tsx[R162-168]

Evidence
In both components, the TSX class assignment includes the enter class and the enterActive class at
the same time during the entering stage, while the SCSS defines these as distinct phases: the enter
class (e.g., .fadeEnter / .swapEnter) provides the initial FROM styles and the enterActive class
(e.g., .fadeEnterActive / .swapEnterActive) provides the transition/TO styles. Because both
classes set overlapping properties like opacity and transform, the active class wins in the cascade
when both are present, causing the initial FROM state to be skipped/overridden rather than applied
first and then transitioned from.

packages/core/src/components/Counter/Counter.tsx[162-168]
packages/core/src/components/Counter/Counter.module.scss[84-109]
packages/core/src/components/MultiStepIndicator/components/StepIndicator/StepIndicator.tsx[221-227]
packages/core/src/components/MultiStepIndicator/components/StepIndicator/StepIndicator.module.scss[106-133]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Counter` and `StepIndicator` apply both their enter FROM and enterActive/TO classes at the same time during the entering stage. Because both classes set overlapping properties (opacity/transform), the “active” class wins in the cascade and the element never starts from the intended enter FROM state, deviating from prior SwitchTransition-style semantics where the entering node begins in the enter class before transitioning to the active class.
## Issue Context
The SCSS for both components is structured as RTG-style two-phase transitions with separate FROM (`.fadeEnter` / `.swapEnter`) and TO+transition (`.fadeEnterActive` / `.swapEnterActive`) classes. The current TSX logic applies both classes simultaneously, which effectively bypasses the FROM state and changes the intended enter/status swap motion.
## Fix Focus Areas
- packages/core/src/components/Counter/Counter.tsx[130-169]
- packages/core/src/components/Counter/Counter.module.scss[84-109]
- packages/core/src/components/MultiStepIndicator/components/StepIndicator/StepIndicator.tsx[195-228]
- packages/core/src/components/MultiStepIndicator/components/StepIndicator/StepIndicator.module.scss[106-133]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit 5cdb200

Results up to commit N/A


🐞 Bugs (2) 📘 Rule violations (1) 📎 Requirement gaps (0)


Action required
1. Toast root missing data-vibe 📘 Rule violation ⚙ Maintainability
Description
The Toast component’s root rendered element (Text/div) does not include the required
data-vibe attribute. This breaks standardized component identification used by design-system
tooling.
Code

packages/core/src/components/Toast/Toast.tsx[R212-224]

Evidence
PR Compliance ID 1 requires each component’s root DOM element to include [data-vibe]. In
Toast.tsx, the returned root element is ` and it does not set data-vibe` at all.

CLAUDE.md: All Components Must Include a Root [data-vibe] Attribute: CLAUDE.md: All Components Must Include a Root [data-vibe] Attribute: CLAUDE.md: All Components Must Include a Root [data-vibe] Attribute: CLAUDE.md: All Components Must Include a Root [data-vibe] Attribute
packages/core/src/components/Toast/Toast.tsx[211-225]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Toast` renders a root `div` (via `Text element="div"`) without the required `data-vibe` attribute.
## Issue Context
Compliance requires every component to expose a standardized `data-vibe` attribute on its root DOM element for consistent identification and tooling.
## Fix Focus Areas
- packages/core/src/components/Toast/Toast.tsx[211-225]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Modal reopen stuck 🐞 Bug ≡ Correctness
Description
In Modal and Toast, if show/open flips back to true while transitionStage is exiting, the
exit timer cleanup can clear the only scheduled transition to exited and there is no logic to move
from exiting back to entering, leaving the component stuck mounted with exit/hidden styles
despite being open. Additionally, when opening from exited, each component can render for one
frame without the required enter “FROM” styles/classes, causing a visible flash/jump before the
enter animation applies.
Code

packages/core/src/components/Modal/Modal/Modal.tsx[R140-168]

Evidence
The transition effect only accounts for opening when the stage is exited/entering and closing
when the stage is entered/entering/exiting, but it does not handle the case where
show/open becomes true while transitionStage==='exiting'; when that prop change happens, the
effect cleanup can cancel the in-flight exit timer, removing the only path to reach exited and
leaving the state machine with no route back to entering. The styling further shows why there is a
visual artifact on open: Modal’s SCSS requires the .containerEnter FROM state “to prevent flash on
mount”, and Toast’s SCSS has a base .toast transform that is already at the final position while
slideIn begins at translate(-50%, -100px), so if the enter class (e.g., .enterActive) is
applied only after an initial paint where transitionStage==='exited', a jump/flash can occur; the
Storybook pattern of keeping Toast mounted and controlling it via open matches this problematic
flow.

packages/core/src/components/Modal/Modal/Modal.tsx[137-174]
packages/core/src/components/Modal/Modal/Modal.module.scss[116-174]
packages/docs/src/pages/components/Modal/ModalBasicLayout.stories.tsx[66-87]
packages/core/src/components/Toast/Toast.tsx[178-209]
packages/core/src/components/Toast/Toast.module.scss[3-130]
packages/docs/src/pages/components/Toast/Toast.stories.tsx[154-217]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Modal and Toast have a transition state machine edge case and a first-open paint glitch.
1) If `show`/`open` becomes `true` while `transitionStage === "exiting"`, the exit timer cleanup may cancel the only scheduled transition to `exited`, and there is no explicit branch to move from `exiting` back to `entering`, leaving the component stuck mounted in exit/hidden styles while it is supposed to be open.
2) When toggling from closed to open (`exited` → open), each component can render one frame in `transitionStage === "exited"` without the required enter “FROM” classes/styles (Modal: missing `.containerEnter`; Toast: missing `.enterActive`/FROM state), causing a visible flash/jump before the enter animation starts.
## Issue Context
- The current effect logic handles opening only from `exited`/`entering` and closing from `entered`/`entering`/`exiting`, but does not handle `open/show === true` while already `exiting`.
- Cleanup functions can clear timers when props change, which can cancel an in-flight exit transition and leave the state machine without a deterministic way to complete or reverse.
- Modal SCSS relies on `.containerEnter` as the FROM state to prevent a mount flash, so it needs to be present before the first paint on open.
- Toast styling uses keyframes (`slideIn`/`slideOut`) and a base `.toast` transform that is already at the final position, so applying the enter class only after a paint can create a noticeable jump; this is especially relevant in controlled usage where Toast remains mounted and is toggled via `open`.
## Fix Focus Areas
- packages/core/src/components/Modal/Modal/Modal.tsx[137-174]
- packages/core/src/components/Toast/Toast.tsx[178-223]
- packages/core/src/components/Toast/Toast.module.scss[3-130]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
3. Toast width recalc no-op 🐞 Bug ≡ Correctness
Description
Toast’s width-recalculation effect is gated on ref.current, but the rendered root element is
attached to nodeRef, so ref.current stays null and recalculateElementWidth never runs
(breaking the intended width transition behavior).
Code

packages/core/src/components/Toast/Toast.tsx[R212-222]

Evidence
The Toast root element is rendered with ref={nodeRef}, but the width recalculation effect checks
ref.current, which is never assigned to any element, so the effect’s body never runs.

packages/core/src/components/Toast/Toast.tsx[90-91]
packages/core/src/components/Toast/Toast.tsx[172-176]
packages/core/src/components/Toast/Toast.tsx[211-225]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Toast` has two refs: `ref` (used by the width-recalculation effect) and `nodeRef` (attached to the rendered `<Text>`). Because `ref` is never attached to any DOM node, `ref.current` is always null and the width-recalculation logic never executes.
### Issue Context
The effect is intended to force width transitions when content changes (see the inline comment referencing transitioning to/from `auto`). Right now it can’t work because it never receives a real element.
### Fix Focus Areas
- packages/core/src/components/Toast/Toast.tsx[90-91]
- packages/core/src/components/Toast/Toast.tsx[172-176]
- packages/core/src/components/Toast/Toast.tsx[211-225]
### Suggested fix
- Remove the extra `ref` and use `nodeRef` for both purposes (recommended), e.g. call `recalculateElementWidth(nodeRef.current)`.
- Ensure the type matches: `useRef<HTMLElement | null>(null)` (or `HTMLDivElement`) and keep `element="div"`.
- Alternatively, attach `ref={ref}` to the root element and stop using `nodeRef` unless it’s needed elsewhere.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Counter enter state skipped ✓ Resolved 🐞 Bug ≡ Correctness
Description
Counter and StepIndicator apply their enter FROM and enterActive/TO classes simultaneously
(fadeEnter+fadeEnterActive, swapEnter+swapEnterActive), so the CSS FROM state (opacity:0; transform:
translateY(15px)) is overridden and never takes effect, altering the intended enter/swap motion.
This differs from the previous SwitchTransition semantics where the entering node began in the enter
FROM class before transitioning to the active state.
Code

packages/core/src/components/Counter/Counter.tsx[R162-168]

Evidence
In both components, the TSX class assignment includes the enter class and the enterActive class at
the same time during the entering stage, while the SCSS defines these as distinct phases: the enter
class (e.g., .fadeEnter / .swapEnter) provides the initial FROM styles and the enterActive class
(e.g., .fadeEnterActive / .swapEnterActive) provides the transition/TO styles. Because both
classes set overlapping properties like opacity and transform, the active class wins in the cascade
when both are present, causing the initial FROM state to be skipped/overridden rather than applied
first and then transitioned from.

packages/core/src/components/Counter/Counter.tsx[162-168]
packages/core/src/components/Counter/Counter.module.scss[84-109]
packages/core/src/components/MultiStepIndicator/components/StepIndicator/StepIndicator.tsx[221-227]
packages/core/src/components/MultiStepIndicator/components/StepIndicator/StepIndicator.module.scss[106-133]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Counter` and `StepIndicator` apply both their enter FROM and enterActive/TO classes at the same time during the entering stage. Because both classes set overlapping properties (opacity/transform), the “active” class wins in the cascade and the element never starts from the intended enter FROM state, deviating from prior SwitchTransition-style semantics where the entering node begins in the enter class before transitioning to the active class.
## Issue Context
The SCSS for both components is structured as RTG-style two-phase transitions with separate FROM (`.fadeEnter` / `.swapEnter`) and TO+transition (`.fadeEnterActive` / `.swapEnterActive`) classes. The current TSX logic applies both classes simultaneously, which effectively bypasses the FROM state and changes the intended enter/status swap motion.
## Fix Focus Areas
- packages/core/src/components/Counter/Counter.tsx[130-169]
- packages/core/src/components/Counter/Counter.module.scss[84-109]
- packages/core/src/components/MultiStepIndicator/components/StepIndicator/StepIndicator.tsx[195-228]
- packages/core/src/components/MultiStepIndicator/components/StepIndicator/StepIndicator.module.scss[106-133]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Qodo Logo

Comment on lines +212 to +224
<Text
ref={nodeRef}
id={id}
data-testid={dataTestId || getTestId(ComponentDefaultTestId.TOAST, id)}
type="text2"
element="div"
color="fixedLight"
className={cx(classNames, {
[styles.enterActive]: transitionStage === "entering",
[styles.exitActive]: transitionStage === "exiting"
})}
role="alert"
aria-live="polite"

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.

Action required

1. Toast root missing data-vibe 📘 Rule violation ⚙ Maintainability

The Toast component’s root rendered element (Text/div) does not include the required
data-vibe attribute. This breaks standardized component identification used by design-system
tooling.
Agent Prompt
## Issue description
`Toast` renders a root `div` (via `Text element="div"`) without the required `data-vibe` attribute.

## Issue Context
Compliance requires every component to expose a standardized `data-vibe` attribute on its root DOM element for consistent identification and tooling.

## Fix Focus Areas
- packages/core/src/components/Toast/Toast.tsx[211-225]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +140 to +168
const [transitionStage, setTransitionStage] = useState<"entering" | "entered" | "exiting" | "exited">(
show ? "entering" : "exited"
);

useEffect(() => {
if (show) {
if (transitionStage === "exited") {
setTransitionStage("entering");
return;
}
if (transitionStage === "entering") {
const timer = setTimeout(() => setTransitionStage("entered"), MODAL_ENTER_MS);
return () => clearTimeout(timer);
}
} else {
if (transitionStage === "entered" || transitionStage === "entering") {
setTransitionStage("exiting");
return;
}
if (transitionStage === "exiting") {
const timer = setTimeout(() => setTransitionStage("exited"), MODAL_EXIT_MS);
return () => clearTimeout(timer);
}
}
}, [show, transitionStage]);

if (!show && transitionStage === "exited") {
return null;
}

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.

Action required

2. Modal reopen stuck 🐞 Bug ≡ Correctness

In Modal and Toast, if show/open flips back to true while transitionStage is exiting, the
exit timer cleanup can clear the only scheduled transition to exited and there is no logic to move
from exiting back to entering, leaving the component stuck mounted with exit/hidden styles
despite being open. Additionally, when opening from exited, each component can render for one
frame without the required enter “FROM” styles/classes, causing a visible flash/jump before the
enter animation applies.
Agent Prompt
## Issue description
Modal and Toast have a transition state machine edge case and a first-open paint glitch.

1) If `show`/`open` becomes `true` while `transitionStage === "exiting"`, the exit timer cleanup may cancel the only scheduled transition to `exited`, and there is no explicit branch to move from `exiting` back to `entering`, leaving the component stuck mounted in exit/hidden styles while it is supposed to be open.

2) When toggling from closed to open (`exited` → open), each component can render one frame in `transitionStage === "exited"` without the required enter “FROM” classes/styles (Modal: missing `.containerEnter`; Toast: missing `.enterActive`/FROM state), causing a visible flash/jump before the enter animation starts.

## Issue Context
- The current effect logic handles opening only from `exited`/`entering` and closing from `entered`/`entering`/`exiting`, but does not handle `open/show === true` while already `exiting`.
- Cleanup functions can clear timers when props change, which can cancel an in-flight exit transition and leave the state machine without a deterministic way to complete or reverse.
- Modal SCSS relies on `.containerEnter` as the FROM state to prevent a mount flash, so it needs to be present before the first paint on open.
- Toast styling uses keyframes (`slideIn`/`slideOut`) and a base `.toast` transform that is already at the final position, so applying the enter class only after a paint can create a noticeable jump; this is especially relevant in controlled usage where Toast remains mounted and is toggled via `open`.

## Fix Focus Areas
- packages/core/src/components/Modal/Modal/Modal.tsx[137-174]
- packages/core/src/components/Toast/Toast.tsx[178-223]
- packages/core/src/components/Toast/Toast.module.scss[3-130]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@github-actions

github-actions Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size Analysis

Changed Components

Component Base PR Diff
Badge 43.19KB 8.56KB -34.63KB 🟢
Counter 42.21KB 7.64KB -34.57KB 🟢
MultiStepIndicator 52.96KB 18.25KB -34.71KB 🟢
Unchanged Components
Component Base PR Diff
@vibe/button 17.3KB 17.29KB -9B 🟢
@vibe/clickable 5.95KB 5.96KB +3B 🔺
@vibe/dialog 52.14KB 52.16KB +14B 🔺
@vibe/icon-button 66.09KB 66.1KB +13B 🔺
@vibe/icon 12.92KB 12.89KB -32B 🟢
@vibe/layer 2.96KB 2.96KB 0B ➖
@vibe/layout 9.82KB 9.83KB +11B 🔺
@vibe/loader 5.64KB 5.65KB +10B 🔺
@vibe/tooltip 61.33KB 61.32KB -7B 🟢
@vibe/typography 63.47KB 63.43KB -47B 🟢
Accordion 6.31KB 6.29KB -14B 🟢
AccordionItem 66.43KB 66.39KB -47B 🟢
AlertBanner 70.83KB 70.9KB +71B 🔺
AlertBannerButton 18.76KB 18.76KB -2B 🟢
AlertBannerLink 15.26KB 15.26KB +4B 🔺
AlertBannerText 63.95KB 63.91KB -38B 🟢
AttentionBox 74.35KB 74.29KB -61B 🟢
Avatar 66.84KB 66.72KB -119B 🟢
AvatarGroup 93.29KB 93.21KB -79B 🟢
BreadcrumbItem 64.7KB 64.64KB -61B 🟢
BreadcrumbMenu 68.57KB 68.58KB +12B 🔺
BreadcrumbMenuItem 77.07KB 77KB -69B 🟢
BreadcrumbsBar 5.68KB 5.68KB -1B 🟢
ButtonGroup 68.32KB 68.3KB -17B 🟢
Checkbox 66.83KB 66.92KB +99B 🔺
Chips 75.05KB 75.01KB -36B 🟢
ColorPicker 74.47KB 74.45KB -26B 🟢
ColorPickerContent 73.73KB 73.7KB -28B 🟢
Combobox 84.08KB 84.03KB -50B 🟢
DatePicker 112.41KB 112.49KB +82B 🔺
Divider 5.42KB 5.46KB +44B 🔺
Dropdown 95.35KB 95.26KB -97B 🟢
EditableHeading 66.63KB 66.53KB -104B 🟢
EditableText 66.46KB 66.42KB -38B 🟢
EmptyState 70.48KB 70.39KB -97B 🟢
ExpandCollapse 66.22KB 66.19KB -32B 🟢
FormattedNumber 5.86KB 5.84KB -13B 🟢
GridKeyboardNavigationContext 4.65KB 4.65KB -4B 🟢
HiddenText 5.4KB 5.39KB -15B 🟢
Info 72.06KB 72.06KB +3B 🔺
Label 68.65KB 68.67KB +21B 🔺
Link 14.91KB 14.88KB -30B 🟢
List 72.88KB 72.84KB -43B 🟢
ListItem 65.54KB 65.49KB -59B 🟢
ListItemAvatar 66.88KB 66.92KB +40B 🔺
ListItemIcon 13.97KB 13.97KB +5B 🔺
ListTitle 65.02KB 64.95KB -69B 🟢
Menu 8.65KB 8.64KB -19B 🟢
MenuDivider 5.56KB 5.57KB +7B 🔺
MenuGridItem 7.16KB 7.19KB +36B 🔺
MenuItem 76.95KB 77.06KB +105B 🔺
MenuItemButton 70.11KB 70.06KB -55B 🟢
MenuTitle 65.35KB 65.34KB -11B 🟢
MenuButton 66.08KB 66.14KB +57B 🔺
Modal 79.14KB 79.14KB -1B 🟢
ModalContent 4.72KB 4.71KB -1B 🟢
ModalHeader 65.79KB 65.77KB -19B 🟢
ModalMedia 7.51KB 7.5KB -3B 🟢
ModalFooter 67.72KB 67.68KB -35B 🟢
ModalFooterWizard 68.6KB 68.56KB -40B 🟢
ModalBasicLayout 8.96KB 8.9KB -57B 🟢
ModalMediaLayout 8.08KB 8.06KB -19B 🟢
ModalSideBySideLayout 6.3KB 6.29KB -4B 🟢
NumberField 72.87KB 72.87KB -6B 🟢
ProgressBar 7.34KB 7.35KB +7B 🔺
RadioButton 65.9KB 65.9KB +3B 🔺
Search 70.65KB 70.61KB -46B 🟢
Skeleton 6KB 6.01KB +4B 🔺
Slider 73.86KB 73.84KB -19B 🟢
SplitButton 66.48KB 66.52KB +46B 🔺
SplitButtonMenu 8.8KB 8.76KB -34B 🟢
Steps 71.31KB 71.36KB +57B 🔺
Table 7.26KB 7.25KB -13B 🟢
TableBody 66.68KB 66.69KB +11B 🔺
TableCell 65.22KB 65.26KB +36B 🔺
TableContainer 5.31KB 5.32KB +16B 🔺
TableHeader 5.64KB 5.64KB +1B 🔺
TableHeaderCell 72.2KB 72.15KB -43B 🟢
TableRow 5.56KB 5.55KB -8B 🟢
TableRowMenu 68.87KB 68.85KB -27B 🟢
TableVirtualizedBody 71.42KB 71.38KB -39B 🟢
Tab 64KB 63.93KB -73B 🟢
TabList 8.89KB 8.86KB -30B 🟢
TabPanel 5.3KB 5.29KB -14B 🟢
TabPanels 5.86KB 5.86KB -2B 🟢
TabsContext 5.48KB 5.51KB +29B 🔺
TextArea 66.26KB 66.25KB -3B 🟢
TextField 69.43KB 69.43KB -2B 🟢
TextWithHighlight 64.35KB 64.3KB -48B 🟢
ThemeProvider 4.36KB 4.36KB -1B 🟢
Tipseen 71.17KB 71.15KB -21B 🟢
TipseenContent 71.6KB 71.6KB -2B 🟢
TipseenMedia 71.27KB 71.3KB +26B 🔺
TipseenWizard 73.93KB 73.8KB -137B 🟢
Toast 74.1KB 74.23KB +130B 🔺
ToastButton 18.59KB 18.62KB +33B 🔺
ToastLink 15.05KB 15.08KB +31B 🔺
Toggle 66.62KB 66.59KB -27B 🟢
TransitionView 5.42KB 5.45KB +30B 🔺
VirtualizedGrid 12.54KB 12.54KB +2B 🔺
VirtualizedList 12.28KB 12.26KB -12B 🟢
List (Next) 8.17KB 8.16KB -15B 🟢
ListItem (Next) 69.88KB 69.82KB -62B 🟢
ListTitle (Next) 65.31KB 65.29KB -21B 🟢

📊 Summary:

  • Total Base Size: 4.75MB
  • Total PR Size: 4.65MB
  • Total Difference: 105.1KB

⚠️ Significant size change detected! Please review the changes carefully.

Update Modal focus lock test to use fake timers so the new CSS-driven
exit transition completes before assertions. Import act from
@testing-library/react since React 16 doesn't export it directly.

Restore Toast snapshot file that was inadvertently removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 5dca595

Counter now animates upward when the value increases (new digit slides
in from the bottom, old slides out to the top) and downward when it
decreases (new from the top, old to the bottom). Uses @Keyframes so the
entrance animation runs reliably without the two-frame trick that
CSSTransition provided.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 5cdb200

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant