Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Performance problem with custom elements when there are many equations #2263

Closed
jxxcarlson opened this issue Dec 13, 2019 · 11 comments
Closed
Labels
Code Example Contains an illustrative code example, solution, or work-around Question v3

Comments

@jxxcarlson
Copy link

Hi -- I've been using the custom element code you provided (Thanks!!!) (see attached at bottom). It works very well when there are not too many math elements, but I run into problems with more complex pages. There is a significant delay -- several seconds -- in rendering such pages. That is, the page does not display until all the rendering is complete.

Here is an example from https://math-markdown.netlify.com, the latest iteration of my Elm + MathJax app:

Example

I've tried to get around this by parsing and rendering the first 4000 characters of the document, then rendering the whole document, so that the user has the illusion of snappiness. However, this is not a great solution, as you will see in the example.

For another view of the problem, go to QFT Notes and click back and forth between the sections
listed in the left-hand table of contents.

Is there a way to improve the performance of the custom elements? I don't know enough about how they work, but here are some possibilities:

  1. All the elements have to complete their rendering before the page displays. If that is the case, maybe a placeholder could be displayed until the actual text is rendered.

  2. Perhaps there is a large amount of set up work that each element has to perform. If so, is there a way that this could be done once, so that all elements could benefit?

NOTE. These performance problems are largely absent during editing because only logical paragraphs that have changed need to be reparsed and rerendered. Moreover -- and this is a huge win -- the jittering that was present during editing with MathJax 2.7 is gone. Bravo!

Two pieces of code attached:

  1. custom-element-config.js
  2. math-text.js

1. custom-element-config.js

MathJax = {
  tex: {inlineMath: [['$', '$'], ['\\(', '\\)']]},
  options: {
    skipHtmlTags: {'[+]': ['math-text']}
  },
  startup: {
    ready: () => {
      //
      //  Get some MathJax objects from the MathJax global
      //
      //  (Ideally, you would turn this into a custom component, and
      //  then these could be handled by normal imports, but this is
      //  just an example and so we use an expedient method of
      //  accessing these for now.)
      //
      const mathjax = MathJax._.mathjax.mathjax;
      const HTMLAdaptor = MathJax._.adaptors.HTMLAdaptor.HTMLAdaptor;
      const HTMLHandler = MathJax._.handlers.html.HTMLHandler.HTMLHandler;
      const AbstractHandler = MathJax._.core.Handler.AbstractHandler.prototype;
      const startup = MathJax.startup;

      //
      //  Extend HTMLAdaptor to handle shadowDOM as the document
      //
      class ShadowAdaptor extends HTMLAdaptor {
        create(kind, ns) {
          const document = (this.document.createElement ? this.document : this.window.document);
          return (ns ?
                  document.createElementNS(ns, kind) :
                  document.createElement(kind));
        }
        text(text) {
          const document = (this.document.createTextNode ? this.document : this.window.document);
          return document.createTextNode(text);
        }
        head(doc) {
          return doc.head || (doc.firstChild || {}).firstChild || doc;
        }
        body(doc) {
          return doc.body || (doc.firstChild || {}).lastChild || doc;
        }
        root(doc) {
          return doc.documentElement || doc.firstChild || doc;
        }
      }

      //
      //  Extend HTMLHandler to handle shadowDOM as document
      //
      class ShadowHandler extends HTMLHandler {
        create(document, options) {
          const adaptor = this.adaptor;
          if (typeof(document) === 'string') {
            document = adaptor.parse(document, 'text/html');
          } else if ((document instanceof adaptor.window.HTMLElement ||
                      document instanceof adaptor.window.DocumentFragment) &&
                     !(document instanceof window.ShadowRoot)) {
            let child = document;
            document = adaptor.parse('', 'text/html');
            adaptor.append(adaptor.body(document), child);
          }
          //
          //  We can't use super.create() here, since that doesn't
          //    handle shadowDOM correctly, so call HTMLHandler's parent class
          //    directly instead.
          //
          return AbstractHandler.create.call(this, document, options);
        }
      }

      //
      //  Register the new handler and adaptor
      //
      startup.registerConstructor('HTMLHandler', ShadowHandler);
      startup.registerConstructor('browserAdaptor', () => new ShadowAdaptor(window));

      //
      //  A service function that creates a new MathDocument from the
      //  shadow root with the configured input and output jax, and then
      //  renders the document.  The MathDocument is returned in case
      //  you need to rerender the shadowRoot later.
      //
      MathJax.typesetShadow = function (root) {
        const InputJax = startup.getInputJax();
        const OutputJax = startup.getOutputJax();
        const html = mathjax.document(root, {InputJax, OutputJax});
        html.render();
        return html;
      }

      //
      //  Now do the usual startup now that the extensions are in place
      //
      MathJax.startup.defaultReady();
    }
  }
};

2. math-text.js

class MathText extends HTMLElement {

   // The paragraph below detects the
   // argument to the custom element
   // and is necessary for innerHTML
   // to receive the argument.
   set content(value) {
  		this.innerHTML = value
  	}

  connectedCallback() {
    this.attachShadow({mode: "open"});
    this.shadowRoot.innerHTML =
      '<mjx-doc><mjx-head></mjx-head><mjx-body>' + this.innerHTML + '</mjx-body></mjx-doc>';
    MathJax.typesetShadow(this.shadowRoot);
  }
}

customElements.define('math-text', MathText)
@dpvc
Copy link
Member

dpvc commented Dec 25, 2019

Because Javascript is single threaded, user interaction can't occur if Javascript is running. MathJax v2 would voluntarily give up the CPU temporarily during its typesetting so that screen updates and user interaction could occur. Your code above doesn't do that, and so all the expressions must be typeset before the screen updates or user interaction occurs.

One possible work-around is to use

    setTimeout(() => MathJax.typesetShadow(this.shadowRoot), 1);

instead of

    MathJax.typesetShadow(this.shadowRoot);

in the connectedCallback() function. This will do each equation individually and allow updates to occur in-between. So you will see all the equations appear one at a time, but will be able to scroll the screen while that happens.

There is a trade-off with this approach, which is that screen updates take time, and so refreshing the window in between each equation will mean that it will take longer to get all the equations rendered this way, but the browser will respond to interaction, and you will see the initial equations earlier.

MathJax v2 would typeset more than one equation at at time before allowing a screen update, which tried to balance responsiveness with update speed. It would take some additional work to make this approach typeset several before updating.

@dpvc dpvc added the Question label Dec 25, 2019
@jxxcarlson
Copy link
Author

Thanks so much Davide, I will give your suggestion a try.

@jxxcarlson
Copy link
Author

Your work-around gives a much better user experience. (Hooray!) I'll test a bit more when I get home next week & then close this issue.

@dpvc
Copy link
Member

dpvc commented Dec 28, 2019

I did a little more testing of the code, and it doesn't seem to work in Safari or Chrome. This is due to the fact that the content doesn't seem to be available during the connectedCallback() function for those platforms, and the set content() function never seems to run (on any platform).

I did some exploring, and it looks like MutationObservers are the only way to handle accessing the contents in a cross-browser way. This also has the side advantage of handling changes to the contents after the element has been added to the page (though you don't use that yourself).

While reading about custom elements, it looks like the recommendation is to set up the shadow DOM in the constructor rather than the connectedCallback() function, so I have modified the code to do that, and to use MutationObservers to handle obtaining the content.

Here is the updated math-text.js:

class MathText extends HTMLElement {

  constructor(...args) {
    super(...args);
    this.attachShadow({mode: "open"});
    this.shadowRoot.innerHTML = '<mjx-doc><mjx-head></mjx-head><mjx-body></mjx-body></mjx-doc>';
    MathText.observer.observe(this, {childList: true});
  }
  
  connectedCallback() {
    MathText.observer.observe(this, {childList: true});
  }

  disconnectedCallback() {
    MathText.observer.disconnect(this, {childList: true});
  }

}

MathText.contentChanged = (list) => {
  for (const item of list) {
    const node = item.target;
    if (node.isConnected) {
      node.shadowRoot.firstChild.lastChild.innerHTML = node.innerHTML;
      setTimeout(() => MathJax.typesetShadow(node.shadowRoot), 1);
    }
  }
}

MathText.observer = new MutationObserver(MathText.contentChanged);

customElements.define('math-text', MathText);

I also made a few changes to the MathJax configuration. The first is to use MathJax.handleRetriedFor() to allow the asynchronous loading of extensions as needed (so if some extension is used that hasn't been loaded, this allows that extension to be loaded before completing the typesetting). The second is to reset the output character cache before each expression is typeset. Because the CSS is based on the actual characters used in the expression, without this reset, they CSS for later expression will include that needed for earlier expressions, even if that CSS isn't used in the current one.

Here is the modified custom-element-config.js file:

MathJax = {
  tex: {inlineMath: [['$', '$'], ['\\(', '\\)']]},
  chtml: {
      displayAlign: 'left'
  },
  options: {
    skipHtmlTags: {'[+]': ['math-text']}
  },
  startup: {
    ready: () => {
      //
      //  Get some MathJax objects from the MathJax global
      //
      //  (Ideally, you would turn this into a custom component, and
      //  then these could be handled by normal imports, but this is
      //  just an example and so we use an expedient method of
      //  accessing these for now.)
      //
      const mathjax = MathJax._.mathjax.mathjax;
      const HTMLAdaptor = MathJax._.adaptors.HTMLAdaptor.HTMLAdaptor;
      const HTMLHandler = MathJax._.handlers.html.HTMLHandler.HTMLHandler;
      const AbstractHandler = MathJax._.core.Handler.AbstractHandler.prototype;
      const startup = MathJax.startup;
      MathJax.handleRetriesFor = MathJax._.mathjax.mathjax.handleRetriesFor;

      //
      //  Extend HTMLAdaptor to handle shadowDOM as the document
      //
      class ShadowAdaptor extends HTMLAdaptor {
        create(kind, ns) {
          const document = (this.document.createElement ? this.document : this.window.document);
          return (ns ?
                  document.createElementNS(ns, kind) :
                  document.createElement(kind));
        }
        text(text) {
          const document = (this.document.createTextNode ? this.document : this.window.document);
          return document.createTextNode(text);
        }
        head(doc) {
          return doc.head || (doc.firstChild || {}).firstChild || doc;
        }
        body(doc) {
          return doc.body || (doc.firstChild || {}).lastChild || doc;
        }
        root(doc) {
          return doc.documentElement || doc.firstChild || doc;
        }
      }

      //
      //  Extend HTMLHandler to handle shadowDOM as document
      //
      class ShadowHandler extends HTMLHandler {
        create(document, options) {
          const adaptor = this.adaptor;
          if (typeof(document) === 'string') {
            document = adaptor.parse(document, 'text/html');
          } else if ((document instanceof adaptor.window.HTMLElement ||
                      document instanceof adaptor.window.DocumentFragment) &&
                     !(document instanceof window.ShadowRoot)) {
            let child = document;
            document = adaptor.parse('', 'text/html');
            adaptor.append(adaptor.body(document), child);
          }
          //
          //  We can't use super.create() here, since that doesn't
          //    handle shadowDOM correctly, so call HTMLHandler's parent class
          //    directly instead.
          //
          return AbstractHandler.create.call(this, document, options);
        }
      }

      //
      //  Register the new handler and adaptor
      //
      startup.registerConstructor('HTMLHandler', ShadowHandler);
      startup.registerConstructor('browserAdaptor', () => new ShadowAdaptor(window));

      //
      //  A service function that creates a new MathDocument from the
      //  shadow root with the configured input and output jax, and then
      //  renders the document.  The MathDocument is returned in case
      //  you need to rerender the shadowRoot later.
      //
      MathJax.typesetShadow = function (root) {
        const InputJax = startup.getInputJax();
        const OutputJax = startup.getOutputJax();
        const html = mathjax.document(root, {InputJax, OutputJax});
        MathJax.handleRetriesFor(() => {
            OutputJax.clearCache();    
            html.render();
        });
        return html;
      }
      
      //
      //  Now do the usual startup now that the extensions are in place
      //
      MathJax.startup.defaultReady();
    }
  }
};

There are some issues you may want to consider. First, there is no guarantee that the expressions will be typeset in the order in which they appear on the page, and that can have consequences on the results. For example, if one expression includes a macro definition that is used later, the order of evaluation is important. If you were to use automatic equation numbering (e.g., AMS equation numbers), then the order is important in order to have the numbering be sequential.

Even without automatic numbering, there may be problems with \label and \ref macros. First, if the \ref is processed before the expression with the \label, and they are in separate html.render() calls (as they will be here, since the html.render() only processes a single expression in the <math-text> elements), then the \ref will be unresolved, and will come out as (???) rather than as a link to the proper equation. This happens in your example page since the in-line math on the page is not in <math-text> elements, and the in-line \ref calls will be processed during the initial typesetting operation, which happens before the displayed equations are typeset. More important, however, is the fact that, since the displayed equations are inside shadow DOM roots, the anchors for their tags are not part of the main document, and so references to them will not be found by the browser, and it won't scroll to them. So even if the \ref is processed after the equation with the corresponding \label, the link from the \ref will not take you to the labeled equation.

Personally, I'm not sure what using the shadow DOM for this really buys you. It is an interesting experiment, and I'm glad that MathJax v3 can be made to handle it, but there are a number of problems that make me wonder if it is worth using it.

@jxxcarlson
Copy link
Author

jxxcarlson commented Jan 5, 2020

Hi Davide, thanks for all the work on this -- and sorry for the late reply. I've been out of touch electronically, which I must say has given me great peace for an extended period!

I'm really anxious to try out the new code, which I'll do tomorrow. Re the shadow DOM, I do need to use custom elements because of the way Elm code interacts with the browser. Is there a way of using them without the shadow DOM?

I've made two optimizations/hacks which help quite a bit. The first I believe I already mentioned -- render the first page or two of the text while the user is reacting to it so that he has the illusion that all is very fast. The second is that I use the timeout > 0 only when loading the doc or when completely re-rendering it at the user's request. During editing, re-rendering is very fast because I diff the new text against the old and only have to-parse-render a tiny bit of the document. The MathJax 3 rendering is really good here because there is no "jittering" of the output as it is re-rendered.

Re labels and refs, I handle these myself, so the out-of order rendering is not a problem as far as visual display is concerned. I did not try to make my handrolled \eqrefs clickable-followable now that you mention this — and so the question is: can I? Maybe so, using the fact that I can make the renderer enclose the math-text element in a div with the correct target for the link. I will give it a try. This is how text-mode links work in MiniLaTeX.

One question — maybe this is related to the out of order problem. Before I could insert macros between double dollar signs and they would be used throughout the document. Is there a way around this?

I really appreciate your help on all of this.

PS. Did you find a good reference for custom elements? I need to improve my understanding of them.

@jxxcarlson
Copy link
Author

I haven't been able to make the new custom element code work. When I inspect the rendered text in the browser, the math elements have no content. I think that this is related to capturing the input to the custom element -- see for example custom element code . In this file I have commented out the old version. The new version is below the old.

In the old version there is the paragraph

   // The "set content" code below detects the
   // argument to the custom element
   // and is necessary for innerHTML
   // to receive the argument.
   set content(value) {
  		this.innerHTML = value
  	}

If this paragraph is deleted, using the old code, the DOM node for the custom element is empty of content.

For a working instance of the old code, see text editor demo. Click on the L1 button at the bottom to get an example with lots of math.

For an instance with the new code, do the same with this link: text editor demo-x

@jxxcarlson
Copy link
Author

re my question above about math-mode macros – I've found a way around this that works quite well, so it is no longer an issue. (The parser reads the macro definitions and applies the substitutions they define to math elements before passing them off to MathJax).

@jxxcarlson
Copy link
Author

I've played around a little more with the math-text custom element code, putting console.log statements here and there to try to understand the issue. The constructor is called, as is the connected callback. However, MathText.contentChanged is never called. The net result is that the custom elements are present in the DOM, but have no content.

@jxxcarlson
Copy link
Author

jxxcarlson commented Feb 27, 2020

Here is a link to a fairly simple app using the old math-text custom elements and with console.log statements track the life cycle. All ok here. set content is called and the math is displayed:

https://demo.minilatex.app/

And here is the buggy version, where set content is not called, but the constructor and connectedCallback are note

https://jxxcarlson.github.io/app/bug.demo.minilatex.io/index.html

@adamma1024
Copy link

Create a worker may be a good idea to reduce the computed in render @dpvc

@jxxcarlson
Copy link
Author

Thanks! will think about that.

@dpvc dpvc added the Code Example Contains an illustrative code example, solution, or work-around label Apr 1, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Code Example Contains an illustrative code example, solution, or work-around Question v3
Projects
None yet
Development

No branches or pull requests

3 participants