software-mansion/react-freeze

When unfreezing the FlatList returns the scroll to the top

yepMad opened this issue · 5 comments

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.

screencap-2022-12-08T023104.731Z.mp4

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

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

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?

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

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