Skip to content

Conversation

@Fanzzzd
Copy link
Contributor

@Fanzzzd Fanzzzd commented Sep 27, 2024

Overview

This PR addresses two long-standing and critical issues in the Masonry component:

  1. A noticeable flicker and layout shift on initial render, where items briefly stack in a single column before arranging correctly.
  2. The layout breaking and collapsing into a single column when the first child item is hidden (e.g., with display: 'none').

These fixes significantly improve the user experience and component stability, making it more reliable for production use.

Key Changes

The solution involves a few core improvements:

  • Synchronous DOM Updates: Replaced the requestAnimationFrame logic with ReactDOM.flushSync inside the resize handler. This ensures that layout calculations and DOM updates are applied synchronously before the browser's next paint, eliminating the root cause of the flicker.
  • Robust Child Observation: Introduced a MutationObserver to reliably detect when child elements are added or removed. This makes the component correctly recalculate its layout when the children change dynamically.
  • Smarter Layout Calculation: The logic now correctly identifies the first visible child for measurements, instead of just the first child in the DOM. This resolves the issue where a hidden first item would break the column calculation.

Demonstration

Before:

before.mov

After:

after.mov

Fixes #36673
Fixes #42611

@mui-bot
Copy link

mui-bot commented Sep 27, 2024

Netlify deploy preview

https://deploy-preview-43903--material-ui.netlify.app/

Bundle size report

Bundle Parsed size Gzip size
@mui/material 0B(0.00%) 0B(0.00%)
@mui/lab 🔺+597B(+1.87%) 🔺+152B(+1.89%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes

Generated by 🚫 dangerJS against e7d8c91

@zannager zannager added package: lab Specific to the lab. scope: masonry Changes related to the masonry. labels Sep 30, 2024
@zannager zannager requested review from DiegoAndai and mnajdova and removed request for mnajdova September 30, 2024 13:24
Fanzzzd and others added 4 commits July 20, 2025 14:25
This commit addresses two long-standing issues with the Masonry component:
1. A flicker/layout shift on initial render, where items would briefly appear in a single column.
2. The layout breaking into a single column when the first item is hidden.

The fix involves:
- Removing `requestAnimationFrame` from the `ResizeObserver` to prevent rendering delays.
- Using `ReactDOM.flushSync` to ensure DOM updates are painted synchronously.
- Introducing a `MutationObserver` to dynamically track and measure children, making the layout updates more robust.
- Modifying the layout logic to find the first *visible* child for width calculation, which correctly handles cases where initial items are hidden.

Fixes mui#36673
Fixes mui#42611
@Fanzzzd Fanzzzd changed the title [Lab][Masonry] Fix hidden first item causing layout to collapseFix masonry hidden item [Lab][Masonry] Fix layout flicker and single column issue Jul 21, 2025
@DiegoAndai
Copy link
Member

Hey @Fanzzzd, thanks for working on this!

Removing requestAnimationFrame might bring back #36909 (see related comment). Have you checked if this regression is not reintroduced?

Would you consider splitting this into two PRs, one for each issue, or is that complicated?


@siriwatknp may I ask you to take a look when you have some time?

The previous change using `flushSync` directly in the `handleResize` callback fixed a layout flicker but introduced a React warning: "flushSync was called from inside a lifecycle method".

This commit wraps the `flushSync` call in a `Promise.resolve().then()` to move it to a microtask. This resolves the warning by ensuring React is not in the middle of a render cycle when `flushSync` is called, while still being fast enough to avoid the visual flicker that `requestAnimationFrame` could cause.

An additional check for `masonryRef.current` is added inside the promise callback to guard against updates on an unmounted component.

Refs mui#36673, mui#42611, mui#36909
@Fanzzzd
Copy link
Contributor Author

Fanzzzd commented Jul 24, 2025

Hey @Fanzzzd, thanks for working on this!

Removing requestAnimationFrame might bring back #36909 (see related comment). Have you checked if this regression is not reintroduced?

Would you consider splitting this into two PRs, one for each issue, or is that complicated?

@siriwatknp may I ask you to take a look when you have some time?

@DiegoAndai , thanks for feedback!

You were right to be cautious about the ResizeObserver loop. I'm pretty confident this change won't bring it back. My last attempt fixed the flicker but caused React warning in the console. This new version (4f83b82) wraps the flushSync in a Promise, which fixes the warning, all without reintroducing the visual flicker.

As for splitting the PR, I'd prefer to keep it as one if that's okay. These two fixes are tied together in the handleResize function. It is easier refactor the layout logic as a whole to make it stable, so splitting it would mean merging a broken or incomplete state. I also think it's easier for you to review the complete solution all at once this way. Hope that makes sense.

I also tested the case in #36909
No error showed. Everything looks good to me.

36909.test.mov

@DiegoAndai DiegoAndai requested a review from siriwatknp July 24, 2025 14:15
@wwz223
Copy link

wwz223 commented Aug 29, 2025

I am waiting for this merge.

@DiegoAndai DiegoAndai removed their request for review September 5, 2025 19:14
@siriwatknp siriwatknp requested a review from Copilot September 9, 2025 04:33
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR fixes critical UX issues in the Masonry component by eliminating layout flicker on initial render and preventing single-column layout when the first child is hidden.

  • Replaces asynchronous ResizeObserver handling with synchronous DOM updates using ReactDOM.flushSync
  • Adds MutationObserver to detect child element changes and dynamically observe/unobserve them
  • Updates layout calculation to use the first visible child instead of the first DOM child

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Comment on lines 287 to 294
Promise.resolve().then(() => {
if (masonryRef.current) {
ReactDOM.flushSync(() => {
setMaxColumnHeight(Math.max(...columnHeights));
setNumberOfLineBreaks(currentNumberOfColumns > 0 ? currentNumberOfColumns - 1 : 0);
});
}
});
Copy link

Copilot AI Sep 9, 2025

Choose a reason for hiding this comment

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

Wrapping flushSync in Promise.resolve().then() defeats the purpose of synchronous updates mentioned in the PR description. This reintroduces asynchronous behavior and may cause the flicker issue to persist. Consider removing the Promise wrapper to ensure truly synchronous DOM updates.

Copilot uses AI. Check for mistakes.
mutationObserver.disconnect();
};
}, [columns, spacing, children, handleResize]);
}, [handleResize]);
Copy link

Copilot AI Sep 9, 2025

Choose a reason for hiding this comment

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

The useEnhancedEffect dependency array only includes handleResize, but the effect also depends on columns, spacing, and children for proper cleanup and re-initialization. When these props change, the observers should be recreated to reflect the new layout requirements.

Suggested change
}, [handleResize]);
}, [handleResize, columns, spacing, children]);

Copilot uses AI. Check for mistakes.
@siriwatknp
Copy link
Member

Key Changes Made:

  1. Replaced Promise wrapper with intelligent flushSync detection
    - Detects if we're in an observer callback where flushSync is safe
    - Falls back to regular state updates in other contexts
    - Eliminates flicker while avoiding React 18 warnings
  2. Added missing effect dependencies
    - Updated useEnhancedEffect to include columns, spacing, children
    - Ensures observers recreate properly when props change
  3. Performance optimization
    - Added 16ms debouncing to resize events (~60fps)
    - Prevents excessive recalculations during rapid resizes
  4. Enhanced test coverage
    - Added comprehensive tests for hidden children scenarios
    - Validates the fix works with hidden first child, dynamic hiding, and all hidden

@siriwatknp siriwatknp self-assigned this Sep 9, 2025
@siriwatknp
Copy link
Member

@Fanzzzd Thanks for the PR, I pushed the latest change as minor improvements.

Can you provide/set up a repo that contain the demos from your description? I'd like to test it visually too.

@Fanzzzd
Copy link
Contributor Author

Fanzzzd commented Sep 18, 2025

@siriwatknp Many thanks for the updates! I can spin up a repo with the demos if you prefer, but the quickest way to verify the fix is in the MUI playground. I’ve added a small demo in docs/pages/playground/index.tsx, dropping the same file into that folder on your side should work as well. If you’d still like a standalone repo, I’m very happy to set one up.

import * as React from 'react';
import Masonry from '@mui/lab/Masonry';
import {
  Box,
  Paper,
  Typography,
  Switch,
  FormControlLabel,
  Button,
  styled,
} from '@mui/material';

const Item = styled(Paper)(({ theme }) => ({
  backgroundColor: theme.palette.mode === 'dark' ? '#1A2027' : '#fff',
  ...theme.typography.body2,
  padding: theme.spacing(0.5),
  textAlign: 'center',
  color: theme.palette.text.secondary,
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
}));

const heights = [150, 30, 90, 70, 110, 150, 130, 80, 50, 90, 100, 150, 30, 50, 80];

/**
 * Delays rendering of its children to simulate items loading at different times.
 */
const DelayedItem = ({ children }: { children: React.ReactElement }) => {
  const [isShown, setIsShown] = React.useState(false);

  React.useEffect(() => {
    const timer = setTimeout(() => {
      setIsShown(true);
    }, Math.random() * 800 + 200);
    return () => clearTimeout(timer);
  }, []);

  return isShown ? children : null;
};

export default function Playground() {
  const [showFirstItem, setShowFirstItem] = React.useState(false);
  const [showMasonry, setShowMasonry] = React.useState(true);

  const handleRemount = () => {
    setShowMasonry(false);
    setTimeout(() => {
      setShowMasonry(true);
    }, 400);
  };

  return (
    <Box
      sx={{
        width: '100%',
        p: 2,
        boxSizing: 'border-box',
      }}
    >
      <Typography variant="h4" gutterBottom>
        Masonry Component Playground
      </Typography>

      <Box sx={{ my: 4 }}>
        <Typography variant="h5" gutterBottom>
          Issue #42611: Layout forms a single column when first item is hidden
        </Typography>
        <Typography mb={2}>
          This section tests the fix for the Masonry layout collapsing into a single column when its
          first child is hidden. Initially, the first item is hidden. With the fix, the layout
          should render correctly across multiple columns. You can toggle the visibility of the
          first item to see the effect.
        </Typography>
        <FormControlLabel
          control={
            <Switch checked={showFirstItem} onChange={(e) => setShowFirstItem(e.target.checked)} />
          }
          label="Show first item"
        />
        <Masonry columns={{ xs: 2, sm: 3, md: 4 }} spacing={2}>
          {heights.map((height, index) => (
            <Item
              key={index}
              sx={{
                height,
                display: index === 0 && !showFirstItem ? 'none' : 'block',
              }}
            >
              {index + 1}
            </Item>
          ))}
        </Masonry>
      </Box>

      <Box sx={{ my: 4 }}>
        <Typography variant="h5" gutterBottom>
          Issue #36673: Layout flicker/shift issue
        </Typography>
        <Typography mb={2}>
          This section is for observing the layout flicker issue. On initial load or when
          re-rendering, the old component would show a single column of items for a moment before
          arranging them into a masonry layout. With the fix, this flicker should be gone. Use the
          button to remount the component and observe the loading behavior. Items are loaded with a
          random delay to simulate real-world conditions like network latency and make any
          flickering more apparent.
        </Typography>
        <Button variant="contained" onClick={handleRemount}>
          Remount Masonry
        </Button>
        <Box sx={{ mt: 2 }}>
          {showMasonry && (
            <Masonry columns={{ xs: 2, sm: 3, md: 4 }} spacing={2}>
              {heights.map((height, index) => (
                <DelayedItem key={index}>
                  <Item sx={{ height }}>{index + 1}</Item>
                </DelayedItem>
              ))}
            </Masonry>
          )}
        </Box>
      </Box>
    </Box>
  );
}

@Fanzzzd
Copy link
Contributor Author

Fanzzzd commented Nov 25, 2025

Hi @siriwatknp , do you still need some more detailed information regarding this PR? I can provide it at any time. I have some new projects that would like to use MUI's Masonry, and updating this PR would help me a lot.

Copy link
Member

@siriwatknp siriwatknp left a comment

Choose a reason for hiding this comment

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

👍 Well done! Thanks for working on this.

@siriwatknp siriwatknp enabled auto-merge (squash) December 1, 2025 06:39
@siriwatknp siriwatknp merged commit 1b40e0f into mui:master Dec 1, 2025
18 checks passed
@Mikilll94
Copy link

Great job! When is this going to be released?

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

Labels

package: lab Specific to the lab. scope: masonry Changes related to the masonry.

Projects

None yet

7 participants