4

Relevant versions: React 16.4.2, Bootstrap 4.1.3, popper.js 1.14.4, Typescript 3.0.3

I use the Bootstrap Popover functionality in my react app.

The Popover works well if the rest of the page is static. When the page is changed (at the browser level), the Popover gets repositioned very quickly and smoothly so it stays visible while the content it's anchored to is visible:

  • when scrolling if it bumps up against the windows edges
  • if the screen is rotated on a phone
  • if the window is resized

This all works well because popper.js is apparently watching the window.scroll and window.resize events, as per this answer: Bootstrap 4 - how does automatic Popover re-positioning work?

The problem comes when my react application starts showing/hiding DOM elements. Because popper.js doesn't know about react, it doesn't know the DOM changed, so it doesn't know that the Popovers might need to be repositioned.

I know calling popover("update") on each Popover anchor works, because I've added code like this to do it intermittently:

window.setInterval(()=> $(this.selfRef).popover("update"), 100);

But that's yucky and wasteful, and a little janky.

Is there a way to have react tell me when it updates any node in the DOM, so I can then tell popper.js to update the position of the popovers?

Note that the react component that causes the DOM change isn't necessarily located near the component that uses the Popover. It could be something in a completely separate part of the hierarchy that happens to be displayed before the component with the popover - so the I don't think the solution is componentWillReceiveProps() or methods like that on the Popover component, because it's probably not the component that's causing the movement.

Note that I'm aware of projects like react-bootstrap, reactstrap or react-popper - but I don't want to use them.


EDIT: it seems like MutationObserver might be a non-react way to do this. I just figured since React is already doing all that reconciliation work, maybe there's a way to get it to notify me when it actually does edit the DOM.

Shorn
  • 19,077
  • 15
  • 90
  • 168

2 Answers2

1

"The react Component that causes the DOM change isn't necessarily located near the Component that uses the Popover. It could be something in a completely separate part of the hierarchy"

If both the Component that changes the DOM, and the Component that creates the Popover are in the same parent, you could share a method in the parent that does the .popover('update'). The Component that changes the DOM would need to trigger this event, but it doesn't need to be specifically "aware" of the Popover Component. The Popover Component doesn't need to be aware of the DOM changing Component.

class ChangeDom extends React.Component {

  constructor(props) {
     super(props);
     this.changeDom = this.changeDom.bind(this);
  }

  changeDom () {
      this.props.domChanged();
  }

  render() {
    return (
    <div>
        <button className="ml-2 btn btn-primary" onClick={this.changeDom}>Change Dom
        </button>
    </div>)
  }
}

class Pop extends React.Component {

  constructor(props) {
     super(props);
     this.togglePopover = this.togglePopover.bind(this);
  }

  togglePopover() {
      $('[data-toggle="popover"]').popover('toggle');
  }

  render() {
    return (
    <div class="position-relative">
        <button className="mt-4 btn btn-primary" onClick={this.togglePopover} data-toggle="popover"
        </button>
    </div>)
  }
}

class Parent extends React.Component {

  domChanged(){
      $('[data-toggle="popover"]').popover("update");
  }

  render() {
    return (
    <div>
        <ChangeDom domChanged={this.domChanged} />
        <Pop />
    </div>)
  }
}

Demo: https://www.codeply.com/go/NhcfE8eAEY

Carol Skelly
  • 351,302
  • 90
  • 710
  • 624
  • You're suggesting adding code to fire this event into every component that changes state in my entire application? – Shorn Sep 05 '18 at 14:01
  • Do all of your components change the DOM? If so yes, since AFAIK that's the only way for the [child component to notify the parent of changes](https://stackoverflow.com/questions/40644092/react-how-to-notify-parent-for-changes). If you use Redux another option may be a dispatch event. – Carol Skelly Sep 05 '18 at 14:08
1

This is my current attempt at a MutationObserver based solution.

UserApp is a component placed toward the top of the application hierarchy. The Popover class is (over) used in various places in my application for a bunch of stuff.

The possibility of infinite recursion caused by firing popover("update") from a MutationObserver event makes me wary of using this solution long term. It seems to do the job for now, but this is one of the things uni-directional binding is meant to avoid.

On the plus side, this works even when you have non-react components in your application (like for example, the Bootstrap navbar).

export class UserApp extends React.Component<any, AppState> {

  public domChangeObservers = $.Callbacks();
  public mutationObserver = new MutationObserver(
    (mutations: MutationRecord[])=>{
      // premature optimisation?
      // I figure I don't care about each individual change, if the browser
      // batched em up, just fire on the last one.
      // But is this a good idea given we have to inspect the mutation in order
      // to avoid recursive loops?
      this.domChangeObservers.fire(mutations[mutations.length-1]);
    }
  );

  constructor(props: any) {
    super(props);

    this.mutationObserver.observe(document.documentElement, {
      attributes: true,
      characterData: true,
      childList: true,
      subtree: true,
      attributeOldValue: true,
      characterDataOldValue: true
    });

  }

  componentWillUnmount(){
    this.mutationObserver.disconnect();
  }

  ...

}


const DefaultTrigger = "click";

export interface PopoverProps{
  popoverTitle: string | Element | Function;
  popoverContent: string | Element | Function;
  /** Set to "focus" to get "dismiss on next click anywhere" behaviour */
  popoverTrigger?:  string;
  /** Leaving it empty means that the popover gets created
   * as a child of the anchor (whatever you use as the child of the popover).
   * Setting this to "body" means the popover gets created out on the body
   * of the document.
   * "body" can help with stuff like when the popover ends up
   * being clipped or "under" other components (because of stuff like
   * `overflow:hidden`).
   */
  container?: string;
  allowDefaultClickHandling?: boolean;

  ignoreDomChanges?: boolean;
  id?: string;
}

export class Popover
extends PureComponent<PopoverProps, object> {

  // ! to hack around TS 2.6 "strictPropertyInitialization"
  // figure out the right way... one day
  selfRef!: HTMLSpanElement;

  onDomChange = (mutation:MutationRecord)=>{
    /*
    - popover("update") causes DOM changes which fire this handler again,
      so we need to guard against infinite recursion of DOM change events.
    - popover("update") is async, so we can't just use an "if not currently
      handling a mutation" flag, because the order of events ends up being:
      onDomChange() -> flag=true -> popover("update") -> flag=false ->
      popper.js changes DOM -> onDomChange() called again -> repeat forever
    - Can't just detect *this* popover. If DOM event occurs because popovers
      overlay each other they will recurse alternately - i.e. pop1 update
      call makes DOM changes for pop2, pop2 update makes changes for pop1,
      repeat forever.
    */
    if( Popover.isPopoverNode(mutation) ){
      return;
    }

    /*
    - tell popper.js to reposition the popover
    - probably not necessary if popover is not showing, but I duuno how to tell
    */
    $(this.selfRef).popover("update");
  };

  private static isPopoverNode(mutation: MutationRecord){
    /*
    Had a good attempt that used the structure of the mutation target to
    see if it's parent element was defined as `data-toggle="popover"`; but
    that fails when you set the `container` prop to some other element -
    especially, "body", see the comments on the Props .
    */

    if( mutation.target.nodeType != 1 ){
      return false;
    }

    // Is Element
    let element = mutation.target as Element;

    /*
     Is the mutation target a popover element?
     As defined by its use of the Bootstrap "popover" class.
     This is dodgy, it relies on Bootstrap always creating a container
     element that has the "popover" class assigned.
     BS could change their classname, or they could
     change how they structure their popover, or some other
     random widget could use the name.
     Actually, this can be controlled by overriding the popover template,
     which I will do... later.
    */
    let isPopoverNode = element.classList.contains("popover");

    // very helpful when debugging - easy to tell if recursion is happening
    // by looking at the log
    // console.log("target", isPopoverNode, mutation, mutation.target );

    return isPopoverNode;
  }

  componentDidMount(): void{
    // the popover() method is a "JQuery plugin" thing,
    // that's how Bootstrap does its stuff
    $(this.selfRef).popover({
      container: this.props.container || this.selfRef,
      placement: "auto",
      title: this.props.popoverTitle,
      content: this.props.popoverContent,
      trigger: this.props.popoverTrigger || DefaultTrigger,
    });

    if( !this.props.ignoreDomChanges ){
      UserApp.instance.domChangeObservers.add(this.onDomChange);
    }

  }

  componentWillUnmount(): void {
    if( !this.props.ignoreDomChanges ){
      UserApp.instance.domChangeObservers.remove(this.onDomChange);
    }

    // - without this, if this component or any parent is unmounted,
    // popper.js doesn't know that and the popover content just becomes
    // orphaned
    $(this.selfRef).popover("dispose");
  }

  stopClick = (e: SyntheticEvent<any>) =>{
    if( !this.props.allowDefaultClickHandling ){
      // without this, if the child element is an <a> or similar, clicking it
      // to show/dismiss the popup will scroll the content
      e.preventDefault();
      e.stopPropagation();
    }
  };

  render(){
    let popoverTrigger = this.props.popoverTrigger || DefaultTrigger;

    // tabIndex is necessary when using "trigger=focus" to get
    // "dismiss on next click" behaviour.
    let tabIndex = popoverTrigger.indexOf("focus")>=0?0:undefined;

    return <span id={this.props.id}
      tabIndex={tabIndex}
      ref={(ref)=>{if(ref) this.selfRef = ref}}
      data-toggle="popover"
      onClick={this.stopClick}
    >{this.props.children}</span>;
  }
}
Shorn
  • 19,077
  • 15
  • 90
  • 168