You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When libraries like motion/react (framer-motion) and @base-ui/react are used together with Preact/compat, exit animations break. The popup disappears instantly instead of animating out.
The root cause is a timing issue: when a component re-renders before its useEffect callbacks have fired (via afterPaint), Preact flushes only that component's stale effects during options._render. This causes effects from different components to run in separate render cycles instead of as a single batch.
In the concrete case:
Select closes → base-ui store updates → multiple components re-render
Base-ui's stale useEffect flushes during one render cycle → calls getAnimations() → finds 0 animations
Motion's stale useEffect flushes during a later render cycle → starts WAAPI animation → too late
Base-ui has already concluded no animations are running → sets hidden → popup disappears
How React handles this
React calls flushPendingEffects() at the start of performSyncWorkOnRoot before beginning any new render work. This flushes all pending passive effects (from all components) as a batch, not just the re-rendering component's effects.
From React's ReactFiberRootScheduler.js:
// In performSyncWorkOnRoot:constdidFlushPassiveEffects=flushPendingEffects();if(didFlushPassiveEffects){returnnull;// Exit to recompute priority}// Then proceed with rendering
// In performWorkOnRootViaSchedulerTask (concurrent path):constdidFlushPassiveEffects=flushPendingEffectsDelayed();if(didFlushPassiveEffects){if(root.callbackNode!==originalCallbackNode){returnnull;}}// Then proceed with rendering
This guarantees that all useEffect callbacks from render N complete before render N+1 begins, regardless of which component is re-rendering.
What Preact did before
In options._render, when a component re-renders with unflushed effects (previousComponent !== currentComponent):
// Old behavior: flush ONLY this component's stale effectshooks._pendingEffects.some(invokeCleanup);hooks._pendingEffects.some(invokeEffect);hooks._pendingEffects=[];
This meant effects from different components could be spread across multiple render cycles when intermediate setState calls triggered cascading re-renders.
The fix
Replace the per-component flush with a call to flushAfterPaintEffects(), which drains the entire pending effects queue:
// New behavior: flush ALL pending effects (matching React)if(hooks._pendingEffects.length){flushAfterPaintEffects();}
This matches React's approach: before starting new render work, ensure all effects from previous renders have completed.
Behavioral differences from the old code
Aspect
Old behavior
New behavior (matches React)
Which effects flush
Only the re-rendering component's
All components with pending effects
Flush order
Re-rendering component first
Tree order (children before parents, as queued by diffed)
When sibling effects run
Deferred to their own re-render or afterPaint
Eagerly, before any re-render proceeds
Parent-effects may fire earlier than before, but at the correct time per React's contract
Effect ordering changes from "re-rendering component first" to "tree order" which matches React
The reason will be displayed to describe this comment to others. Learn more.
Isn't this the wrong branch of this condition? The fix should be in the branch where currentComponent === previousComponent since it's for the case where the component is dirtied mid-render?
The reason will be displayed to describe this comment to others. Learn more.
This is in the right branch, when we immediately re-render due to an in-render setState we will reset the components hooks state. When we however have overlapping renders where we see that we have rendered this component before and it still has pending-effects to flush we will flush it. Now instead of flushing the component only we flush the entire subtree.
The reason will be displayed to describe this comment to others. Learn more.
The main reason for this is that nothing currently stops the following from happening
Queue update
Update runs, our root and an intermediary component enqueues an effect run
Child queues an update for parent
BEFORE: we flush the intermediary effect
AFTER: we flush both intermediary as well as root effect
intermediary renders again
Between 3 and 4 we would flush the parent updates
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reproduction at https://stackblitz.com/edit/vitejs-vite-yxpjutvm?file=package.json - notice how the fade-out animation never triggers and instead it just dissapears
Problem
When libraries like
motion/react(framer-motion) and@base-ui/reactare used together with Preact/compat, exit animations break. The popup disappears instantly instead of animating out.The root cause is a timing issue: when a component re-renders before its
useEffectcallbacks have fired (viaafterPaint), Preact flushes only that component's stale effects duringoptions._render. This causes effects from different components to run in separate render cycles instead of as a single batch.In the concrete case:
useEffectflushes during one render cycle → callsgetAnimations()→ finds 0 animationsuseEffectflushes during a later render cycle → starts WAAPI animation → too latehidden→ popup disappearsHow React handles this
React calls
flushPendingEffects()at the start ofperformSyncWorkOnRootbefore beginning any new render work. This flushes all pending passive effects (from all components) as a batch, not just the re-rendering component's effects.From React's
ReactFiberRootScheduler.js:This guarantees that all
useEffectcallbacks from render N complete before render N+1 begins, regardless of which component is re-rendering.What Preact did before
In
options._render, when a component re-renders with unflushed effects (previousComponent !== currentComponent):This meant effects from different components could be spread across multiple render cycles when intermediate
setStatecalls triggered cascading re-renders.The fix
Replace the per-component flush with a call to
flushAfterPaintEffects(), which drains the entire pending effects queue:This matches React's approach: before starting new render work, ensure all effects from previous renders have completed.
Behavioral differences from the old code
diffed)afterPaint