Skip to content

Conversation

@chenxinyanc
Copy link
Contributor

Current Behavior

Rendering <Tooltips> will force style recalculations during toggleScrollListener calls.

New Behavior

toggleScrollListener calls are deferred until the next animation frame, when the browser has already calculated the styles and done page layout.

Related Issue(s)

Fixes #25326

@fabricteam
Copy link
Collaborator

Perf Analysis (@fluentui/react-components)

No significant results to display.

All results

Scenario Render type Master Ticks PR Ticks Iterations Status
Avatar mount 1269 1269 5000
Button mount 923 925 5000
FluentProvider mount 1494 1489 5000
FluentProviderWithTheme mount 575 571 10
FluentProviderWithTheme virtual-rerender 539 547 10
FluentProviderWithTheme virtual-rerender-with-unmount 588 579 10
MakeStyles mount 1940 1998 50000
SpinButton mount 2318 2354 5000

@size-auditor
Copy link

size-auditor bot commented Oct 21, 2022

Asset size changes

Size Auditor did not detect a change in bundle size for any component!

Baseline commit: a96544608a06750d4e551f13de68884e1a287fec (build)

@codesandbox-ci
Copy link

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 985b06b:

Sandbox Source
@fluentui/react 8 starter Configuration
@fluentui/react-components 9 starter Configuration
Tooltip and computed style Issue #25326

@fabricteam
Copy link
Collaborator

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-combobox
Combobox (including child components)
75.85 kB
24.632 kB
75.926 kB
24.649 kB
76 B
17 B
react-combobox
Dropdown (including child components)
75.579 kB
24.594 kB
75.655 kB
24.611 kB
76 B
17 B
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
189.603 kB
52.909 kB
189.679 kB
52.931 kB
76 B
22 B
react-menu
Menu (including children components)
116.78 kB
36.112 kB
116.856 kB
36.125 kB
76 B
13 B
react-menu
Menu (including selectable components)
119.849 kB
36.635 kB
119.925 kB
36.645 kB
76 B
10 B
react-popover
Popover
103.342 kB
31.82 kB
103.418 kB
31.835 kB
76 B
15 B
react-positioning
usePositioning
19.724 kB
7.415 kB
19.8 kB
7.438 kB
76 B
23 B
react-tooltip
Tooltip
41.718 kB
14.687 kB
41.794 kB
14.707 kB
76 B
20 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-alert
Alert
84.688 kB
21.233 kB
react-avatar
Avatar
48.874 kB
13.864 kB
react-avatar
AvatarGroup
14.996 kB
6.013 kB
react-avatar
AvatarGroupItem
68.842 kB
19.205 kB
react-components
react-components: Button, FluentProvider & webLightTheme
62.94 kB
17.663 kB
react-components
react-components: FluentProvider & webLightTheme
33.446 kB
11.033 kB
react-persona
Persona
53.992 kB
15.25 kB
react-portal-compat
PortalCompatProvider
5.857 kB
1.978 kB
🤖 This report was generated against a96544608a06750d4e551f13de68884e1a287fec


updatePosition();
// toggleScrollListener requires computed styles; thus use RAF to prevent forced style reevaluation.
window.requestAnimationFrame(() => {
Copy link
Member

Choose a reason for hiding this comment

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

We need to use a proper window instance, otherwise requestAnimationFrame may not be triggered properly.

You can get it from useFluent() (it's already used in this component):

const { targetDocument } = useFluent();

targetDocument?.defaultView?.requestAnimationFrame()


updatePosition();
// toggleScrollListener requires computed styles; thus use RAF to prevent forced style reevaluation.
window.requestAnimationFrame(() => {
Copy link
Member

Choose a reason for hiding this comment

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

All requestAnimationFrame() calls should be cleaned up on a component to avoid reference/memory leaks or throwing exceptions. Typically it's:

const requestRef = React.useRef();

// ---
requestRef.current = requestAnimationFrame();
// ---

React.useEffect(() => {
  return () => {
    cancelAnimationFrame(requestRef.current);
  };
}, []);

@layershifter
Copy link
Member

layershifter commented Oct 31, 2022

@chenxinyanc thanks for the PR (and the detailed issue) 👍 I left some comments about proposed implementation.


However, I think that the problem has different roots: it's not expected that positioning will executed until a tooltip is visible. On the initial look it seems that it should work like that:

const positioningOptions = {
enabled: state.visible,

And indeed positioning logic is never triggered until it's needed:

const forceUpdate = useEventCallback(() => {
const target = overrideTargetRef.current ?? targetRef.current;
if (!canUseDOM || !enabled || !target || !containerRef.current) {
return;
}

The problem is that as you mentioned/noticed we add event listeners in useTargetRef() and useContainerRef() that are executed always when refs are resolved and call this costly logic 💥

Interesting that it was different before we moved to Floating UI (#24254): we had an instance of Popper object and it was responsible for adding listeners. Now we have a cleaner solution, but it causing this problem 😕

I was looking on how to solve it and came with something like (code needs some refactor! 🚨):

function useContainerRef(updatePosition: () => void, enabled: boolean) {
  const containerRef = React.useRef<HTMLElement | null>();

  React.useEffect(() => {
    if (enabled && containerRef.current) {
      const scrollParent = getScrollParent(containerRef.current);
      scrollParent.addEventListener('scroll', updatePosition);

      updatePosition();
    }
  }, [enabled, updatePosition]);

  return useCallbackRef<HTMLElement | null>(null, (container, prevContainer) => {
    if (prevContainer) {
      const prevScrollParent = getScrollParent(prevContainer);
      prevScrollParent.removeEventListener('scroll', updatePosition);
    }

    containerRef.current = prevContainer;

    if (container && enabled) {
      // When the container is first resolved, set position `fixed` to avoid scroll jumps.
      // Without this scroll jumps can occur when the element is rendered initially and receives focus
      Object.assign(container.style, { position: 'fixed', left: 0, top: 0, margin: 0 });

      const scrollParent = getScrollParent(container);
      scrollParent.addEventListener('scroll', updatePosition);

      updatePosition();
    }
  });
}

function useTargetRef(updatePosition: () => void, enabled: boolean) {
  const targetRef = React.useRef<HTMLElement | PositioningVirtualElement | null>();

  React.useEffect(() => {
    if (enabled && targetRef.current instanceof HTMLElement) {
      const scrollParent = getScrollParent(targetRef.current);
      scrollParent.addEventListener('scroll', updatePosition);

      updatePosition();
    }
  }, [enabled, updatePosition]);

  return useCallbackRef<HTMLElement | PositioningVirtualElement | null>(null, (target, prevTarget) => {
    if (prevTarget instanceof HTMLElement) {
      const prevScrollParent = getScrollParent(prevTarget);
      prevScrollParent.removeEventListener('scroll', updatePosition);
    }

    targetRef.current = target;

    if (enabled && target instanceof HTMLElement) {
      const scrollParent = getScrollParent(target);
      scrollParent.addEventListener('scroll', updatePosition);

      updatePosition();
    }
  });
}

In this case we don't call getScrollParent() until a tooltip is visible and don't bind event listeners. I used your example to check results:

Current master

image

The change

image

@layershifter
Copy link
Member

A different fix was applied in #25456.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: getScrollParent is triggering forced reflow, impacting boot performance

5 participants