software-mansion / react-freeze

Prevent React component subtrees from rendering.
MIT License
1.54k stars 33 forks source link

When unfreezing the FlatList returns the scroll to the top #31

Open yepMad opened 1 year ago

yepMad commented 1 year ago

Hello,

My current component is structured as follows, take into account that isFocused comes from the React Navigation hook:

<Freeze freeze={!isFocused}>
      <FlatList
        data={data}
        removeClippedSubviews
        renderItem={renderItem}
        keyExtractor={keyExtractor}
        getItemLayout={getItemLayout}
        ItemSeparatorComponent={Divider}
      />
</Freeze>

When the user returns to this screen after being frozen, the FlatList scroll is at the top, is this the expected behavior? From the README I understood that it shouldn't happen. Here is a video to illustrate the problem. For tabs I'm using @react-navigation/material-top-tabs.

https://user-images.githubusercontent.com/22564368/206342342-98c999f5-a602-4930-88d2-092abb2756a3.mp4

leandronorcio commented 1 year ago

This also happens to <ScrollView>, it freezes the component as intended but every time you unfreeze it, it scrolls to the top.

bkdiehl commented 1 year ago

This issue appears to occur because Suspense is adding the style display: 'none' to its suspended children. We worked around the issue with something like the following.

const [freeze, setFreeze] = useState(false);
const [placeholder, setPlaceholder] = useState(null);

const handleFreeze = (value: boolean) => {
  setFreeze(value);
  if (value) {
    const __html = document.getElementById('freezeBlock')?.innerHTML ?? '';
    setPlaceholder(<div dangerouslySetInnerHTML={{ __html }} />);
  } else {
    setPlaceholder(null);
  }
};

return (
  <Freeze freeze={freeze} placeholder={placeholder}>
    <div id="freezeBlock">{children}</div>
  </Freeze>
);

I think the root of the issue lies with React.Suspense not providing a better way to opt out of having suspended children hidden by default. There is the useTransition hook, but I wasn't able to figure out how to get that to work with the concept of a boolean value that triggers suspension.

yepMad commented 1 year ago

This issue appears to occur because Suspense is adding the style display: 'none' to its suspended children. We worked around the issue with something like the following.

const [freeze, setFreeze] = useState(false);
const [placeholder, setPlaceholder] = useState(null);

const handleFreeze = (value: boolean) => {
  setFreeze(value);
  if (value) {
    const __html = document.getElementById('freezeBlock')?.innerHTML ?? '';
    setPlaceholder(<div dangerouslySetInnerHTML={{ __html }} />);
  } else {
    setPlaceholder(null);
  }
};

return (
  <Freeze freeze={freeze} placeholder={placeholder}>
    <div id="freezeBlock">{children}</div>
  </Freeze>
);

I think the root of the issue lies with React.Suspense not providing a better way to opt out of having suspended children hidden by default. There is the useTransition hook, but I wasn't able to figure out how to get that to work with the concept of a boolean value that triggers suspension.

Thank you so much for this! Any idea how it can be solved on mobile?

bkdiehl commented 1 year ago

Thank you so much for this! Any idea how it can be solved on mobile?

@yepMad

By mobile, do you mean something like react-native? I've only worked with React for strict web developement. I'm not sure if this is available in react-native or not, but I had a coworker just mention Mutation Observers. It's funny all the basic things you keep learning about in the javascript ecosystem. Anyways, I was able to refactor the previous code so that the dom isn't being duplicated anymore. Using the mutation observer, we can catch Suspense adding display: 'none !important and prevent it from happening. This could could probably be refined, but hopefully the gist of it is clear enough.

let observer: MutationObserver | undefined;
export function FreezeProvider({ children, freeze }: { children: React.ReactElement, freeze?: boolean }) {
  useEffect(() => {
    if (observer) return;
    observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (mutation.type !== 'attributes' || mutation.attributeName !== 'style') continue;
        const target = mutation.target as HTMLElement;
        if (target.getAttribute('style')?.includes('display: none !important;'))
          target.setAttribute('style', '');
      }
    });

    // this is used to ensure the mutation observer is correctly assigned
    const fetchFreezeBlockInterval = setInterval(() => {
      const freezeBlockEl = document.getElementById('freezeBlock');
      if (!freezeBlockEl || !observer) return;
      observer.observe(freezeBlockEl, {
        attributeFilter: ['style'],
      });
      clearInterval(fetchFreezeBlockInterval);
    }, 1000);
  }, []);

  return (
    <Freeze freeze={freeze} placeholder={null}>
      <div id="freezeBlock">{children}</div>
    </Freeze>
  );
}
yepMad commented 1 year ago

@bkdiehl Oh, sorry. I meant react-native. This solution is only for React Web, right?

uroge commented 2 months ago

@bkdiehl Your component will keep children freezed only if props don't change, but if some of child components are subscribed to some state update (e.g. using WebSockets) it will still be re-rendering in the background.