Hello World! My name is Oleksandr, I am Senior
Software Engineer at Microsoft Prague.
I work on Fluent UI and someone may know
me as a maintainer of Semantic UI React.
Today we will talk about positioning
of popover elements in Fluent UI v9.
Time to time there will be links for reference
so you could get more details
and background behind decisions.
Each link will contain a number. That
number identifies it and in description
you will find whole list with all links.
So, let's go to a meeting room.
In short, we use PopperJS.
It's an awesome library,
it does a lot for us.
However, let me explain why
we decided to use PopperJS.
And for this we will need to have
a short Fluent UI history lesson.
I will talk mainly about
implementation in Fluent UI v9.
However, Fluent UI React has two predecessors.
Office UI Fabric and Stardust UI.
Now it's easy to clarify versioning.
v8 is former Office UI Fabric,
while v0 is former Stardust UI.
v0 is also known as
Fluent UI React Northstar.
As Stardust UI was initially
a fork of Semantic UI React.
We inherited its position logic.
Now you have a complete picture.
As I said, we inherited positioning
logic from Semantic UI React.
It was simple and implemented
directly in a Popup component.
In that implementation a popover
component positions a popover around
a trigger and handles few positioning
values for positioning properties.
The original logic was around
100 lines of code and it haven't
additional handling for more
complex cases that were reported
later via GitHub issues. Initially when we
implemented popup component in Stardust UI,
we took that positioning
logic and modified it.
We added support for right to
left text direction and it worked for us.
As in this case,
a popover element should just
flip its position horizontally.
But there I would like to make
a small stop in Stardust UI
we started to work on Popup component
after it existed for two years in
Semantic UI React. And there in
positioning logic on Semantic UI React side
we already made a lot of bug fixes
and completed feature requests from
the community. But it wasn't enough.
Let's go by example.
So what happens when there is not
enough space above or below the trigger?
Maybe we will need to flip the content
and then if the user scrolls up or down,
maybe we will need to flip it automatically.
In our scenario,
we need to apply some margin between
a popover and trigger elements,
but we want to keep positioning logic.
In one case it should be static,
in another we would like to compute
it based on element size.
Is it possible?
Hey guys! I have just returned from
a meeting with a partner.
They really like the new components.
They are happy to use them as soon as possible.
There is just one thing they
still need to support IE11.
Of course it wasn't a real life conversation,
but it shows how we collected requirements
and how we realized that it's easier
to use an existing library that is
widely adopted by the community.
Which is extensible and contains required features.
Honestly, there are not many
options from what we could choose.
That's why we picked PopperJS.
It's used in Material UI, Bootstrap,
in Semantic UI React
and now in Fluent.
Initially, we used existing
React bindings for PopperJS.
The package is called
"react-popper" and it's awesome.
However, once we shipped our Popup
component into production application,
we noticed that it caused performance issues.
"react-popper" has one design decision
that we didn't like and personally
I still don't agree with it.
PopperJS bindings for React
delegate all DOM manipulations
to components.
This means that on every change of
styles when PopperJS recomputes
positioning - we will get a re-render.
This is a huge issue for
applications like Teams,
where popups can contain complex components.
And re-rendering them on each scroll
movement is not a good idea.
At the same time,
PopperJS itself can apply
styles to DOM elements.
It's a built-in feature. So can't we use it?
Yes,
that's why we created Popper
component in Stardust UI.
The fun fact is that the folks from
Material UI actually doing the same thing.
Our main goal was to reduce
re-renders and with direct DOM manipulations
we were able to reach it.
I mentioned that PopperJS does a lot for us,
but there are a few things that we
implemented based on customers' requests.
These features are implemented
as PopperJS modifiers.
For example,
we have custom modifiers like "coverTarget"
and "autoSize".
Our popover element now looks great, but
not so great when it's on smaller
screen or bigger zoom percentage.
In fact it is cropped.
We introduced a new "autoSize" feature,
so the popover element can shrink or
expand itself according to the viewport size.
When a popover element is so big and
there is not enough space to fit it in,
it will shrink itself automatically
and have its content scrollable.
The implementation of "autoSize"
was supersmooth.
This is because there is already
an utility in PopperJS,
that helps us calculate the size
of the viewport and the size
of the popover element.
It is called "detectOverflow".
Also we got inspiration from
an existing PopperJS modifier
called "popper-max-size-modifier".
We made updates based on this modifier,
so we can have only auto height or
only auto width to suit certain use cases.
It's not uncommon in Web UIs
to see a popover cover the target
element that it's anchored to.
For example, when you click on a button to open a menu,
the menu could cover the
button that opened it.
Users could do this already
with the Offset API for
position elements in Fluent.
However,
the offsets we need to change
based on each possible position
of the popover element.
To make this easier,
we've created a new modifier for this.
This modifier acts as syntactic sugar for
a set of offset calculations for each
possible position of the popover element,
with respect to the target element.
Since this modifier is built into
our existing modifier pipeline,
you'll get Right-to-Left, flipping
and scrolling support for free.
A few times we got reports from
developers who were implementing their
features using Popover that they
encountered a glitch.
Layout was jumping after Popover was opened.
We spent quite some time before we
realized that there are two different
cases of this behavior.
The first case is related to how React is
handling auto focus on HTML primitives.
React calls focus on elements before any
other code such as effects is executed.
There is no way how we could change that.
To save our customers from
debugging such a complex issue,
we've added a warning to be logged into
console whenever any component inside
a popover is using "autoFocus" prop.
When elements inside popover
have custom focus handling,
we will also have layout jumps.
That's the second case.
When popover is added to the DOM,
it has only styles for "position: absolute".
And since it is inserted
directly into "document.body",
it will appear in the top left corner.
Then the focus is called on
the element inside popover.
And window scroll jumps up.
Only after that
Popper's "forceUpdate" method is executed to
compute positioning.
All PopperJS modifies are run and a Popper
instance is positioned correctly,
but window scroll is not updated.
To solve this issue we have created
PopperJS modifier which is executed
before any other modifier and sets
popover position to be "fixed".
And after all computations are done
it restores it back to the initial value.
Unfortunately, it wasn't our last issue with React
and our positioning implementation.
There are two kinds of refs in React:
via callbacks and via objects.
Let's take a look on the comparison.
Refs via objects can be only accessed,
you will not get any notifications
when an element is mounted or unmounted.
At the same time, object refs will not trigger re-renders.
That's why we use them in Popper
component to assign elements to refs.
However, usage of object refs in public API
may be unsafe.
It's what we learned.
There are two main issues
that we discovered.
First, is that an element could change
and positioning will be broken.
And it's a real case from an application.
With object refs it cannot be handled.
It's a rare usage,
it was reported only few times
and we had a workaround for it.
We suggested to wrap an element
with a "div" and it worked.
But, again it's not a solution,
it's a workaround.
Second problem was related to a fact
that refs and effects in React are
mounted and executed in order.
They bubble up in React's tree.
Usually it's not a problem,
but in our case with ChatMessage component.
We have a trigger and a popover
that have child-parent relationship:
a toolbar with actions should
be positioned around a message,
while the toolbar itself is
rendered inside the message.
So when refs are resolved we will get "null"
instead of an element in message ref
because of the order of execution.
Hey, shift! What is the problem?
Let's use callback refs with state.
Hm... No problems, let's do it.
We will deal with double rendering of chat message,
but I don't see any issues with it.
So previously we decided to use callback
refs for the case that I described
before with chat message. And accidentally
the decision shot into our legs.
In Microsoft Teams as in any big product
we have a lot of different teams.
One of them works on memory issues and
we got a report from them about huge
memory allocation during initial load.
As we discovered later, it was caused
by second render of chat message.
It contributed around 25 megabytes
to overall usage and from overall
usage it was around 20%.
Not cool
and as Fluent UI team we had to solve it.
We can't use callback refs
as they will produce for re-renders.
We can't use object refs as they will not
work in our scenario as we would expect.
Is there some middle ground between them?
Yes, there's a small library called
"use-callback-ref" that perfectly closes
the gap between object and callback refs.
Refs created by the "useCallbackRef" hook
behave like object refs
and don't do not result in re-renders.
At the same time once a ref is updated,
we will be notified via a callback.
Now we can create instances of PopperJS
and handle updates of elements properly.
The idea of "use-callback-ref" solved our issues
with refs and it's how we created
our custom "usePopper" hook.
From an existing Popper component.
The key thing there that we decoupled
creation of PopperJS instances from React lifecycle.
Our "usePopper" hook creates
instances based on ref updates.
However, there is downside of this approach -
it makes implementation trickier.
For example, we still need to handle updates of
PopperJS options in React lifecycle.
I would like to mention that
"use-callback-ref" is not a silver bullet:
it should be used on demand and carefully
as it complicates implementation
and may lead to unexpected bugs.
We agreed to not expose modifiers
from PopperJS directly
in our components via props.
However, we still need to have them exposed
in "usePopper" hook to handle few
positioning edge cases in our components.
This moves us to the same problem related to refs.
Sometimes, modifiers need access for
positioning purposes to a DOM node
and we will deal again with double
rendering or with object refs as
somehow we need to provide these elements.
To prevent these issues,
modifiers in "usePopper" hook
can be specified as a closure
that will be resolved
during creation of PopperJS instance
with popover and trigger elements.
We came a long path with PopperJS.
First, we used "react-popper" package.
Then we implemented Popper component and
only after that we moved to "usePopper" hook.
It was an interesting journey
and we learned a lot.
But, how all this is related to Fluent UI v9?
When we started development of v9
there were components like
Popover, Menu and Tooltip.
These components needed positioning logic
and it was a blocking issue for their implementation.
Refactor that I made to get "usePopper"
was done at the right moment and it was
the easiest way to get robust positioning
logic added quickly.
Another option was to implement
positioning library by ourselves,
but as we know it might not be a good
idea and would require heavy resourcing.
That was the first episode of
Fluent UI Insights.
Now you know how we chose positioning library
What happened behind the scenes.
Why we have our custom "usePopper" hook and
what custom features
our team implemented.
See you next time!