diff --git a/packages/rendermime/src/renderers.ts b/packages/rendermime/src/renderers.ts index b608c723fdd0..cc5156d7ded8 100644 --- a/packages/rendermime/src/renderers.ts +++ b/packages/rendermime/src/renderers.ts @@ -809,6 +809,16 @@ function nativeSanitize(source: string): string { return el.innerHTML; } +/** + * Enum used exclusively for static analysis to ensure that each + * branch in `renderFrame` leads to explicit rendering decision. + */ +enum RenderingResult { + stop, + delay, + continue +} + /** * Render the textual representation into a host node. * @@ -821,85 +831,428 @@ function renderTextual( // Unpack the options. const { host, sanitizer, source } = options; - const ansiPrefixRe = /\x1b/; // eslint-disable-line no-control-regex - const hasAnsiPrefix = ansiPrefixRe.test(source); + // We want to only manipulate DOM once per animation frame whether + // the autolink is enabled or not, because a stream can also choke + // rendering pipeline even if autolink is disabled. This acts as + // an effective debouncer which matches the refresh rate of the + // screen. + Private.cancelRenderingRequest(host); + + // Stop rendering after 10 minutes (assuming 60 Hz) + const maxIterations = 60 * 60 * 10; + let iteration = 0; + + let fullPreTextContent: string | null = null; + let pre: HTMLPreElement; + + let isVisible = false; + + // We will use the observer to pause rendering if the element + // is not visible; this is helpful when opening a notebook + // with a large number of large textual outputs. + let observer: IntersectionObserver | null = null; + if (typeof IntersectionObserver !== 'undefined') { + observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + isVisible = entry.isIntersecting; + if (isVisible) { + wasEverVisible = true; + } + } + }, + { threshold: 0 } + ); + observer.observe(host); + } + let wasEverVisible = false; + + const stopRendering = () => { + // Remove the host from rendering queue + Private.removeFromQueue(host); + // Disconnect the intersection observer. + observer?.disconnect(); + return RenderingResult.stop; + }; - // Create the HTML content: - // If no ANSI codes are present use a fast path for escaping. - const content = hasAnsiPrefix - ? sanitizer.sanitize(Private.ansiSpan(source), { - allowedTags: ['span'] - }) - : nativeSanitize(source); + const continueRendering = () => { + iteration += 1; + Private.scheduleRendering(host, renderFrame); + return RenderingResult.continue; + }; - // Set the sanitized content for the host node. - const pre = document.createElement('pre'); - pre.innerHTML = content; + const delayRendering = () => { + Private.scheduleRendering(host, renderFrame); + return RenderingResult.delay; + }; - const preTextContent = pre.textContent; + const renderFrame = (timestamp: number): RenderingResult => { + if (!host.isConnected && wasEverVisible) { + // Abort rendering if host is no longer in DOM; note, that even in + // full windowing notebook mode the output nodes are never removed, + // but instead the cell gets hidden (usually with `display: none` + // or in case of the active cell - opacity tricks). + if (!wasEverVisible) { + // If the host was never visible, it means it was not yet + // attached when loading notebook for the first time. + return delayRendering(); + } else { + return stopRendering(); + } + } - const cacheStoreOptions = []; - if (autoLinkOptions.checkWeb) { - cacheStoreOptions.push('web'); - } - if (autoLinkOptions.checkPaths) { - cacheStoreOptions.push('paths'); - } - const cacheStoreKey = cacheStoreOptions.join('-'); - let cacheStore = Private.autoLinkCache.get(cacheStoreKey); - if (!cacheStore) { - cacheStore = new WeakMap(); - Private.autoLinkCache.set(cacheStoreKey, cacheStore); - } + // Delay rendering of this output if the output is not visible due to + // scrolling away in full windowed notebook mode, or if another output + // should be rendered first. + // Note: cannot use `checkVisibility` as it triggers style recalculation + // before we appended the new nodes from the stream, which leads to layout + // trashing. Instead we use intersection observer + if (!isVisible || !Private.canRenderInFrame(timestamp, host)) { + return delayRendering(); + } - let ret: HTMLPreElement; - if (preTextContent) { - // Note: only text nodes and span elements should be present after sanitization in the `
` element.
+    const start = performance.now();
+    Private.beginRendering(host);
+
+    if (fullPreTextContent === null) {
+      const ansiPrefixRe = '\x1b';
+      const hasAnsiPrefix = source.includes(ansiPrefixRe);
+
+      // Create the HTML content:
+      // If no ANSI codes are present use a fast path for escaping.
+      const content = hasAnsiPrefix
+        ? sanitizer.sanitize(Private.ansiSpan(source), {
+            allowedTags: ['span']
+          })
+        : nativeSanitize(source);
+
+      // Set the sanitized content for the host node.
+      pre = document.createElement('pre');
+      pre.innerHTML = content;
+
+      const maybePreTextContent = pre.textContent;
+
+      if (!maybePreTextContent) {
+        // Short-circuit if there is no content to auto-link
+        host.replaceChildren(pre);
+        return stopRendering();
+      }
+      fullPreTextContent = maybePreTextContent;
+    }
+
+    const shouldAutoLink = sanitizer.getAutolink?.() ?? true;
+
+    if (!shouldAutoLink) {
+      host.replaceChildren(pre.cloneNode(true));
+      return stopRendering();
+    }
+    const cacheStore = Private.getCacheStore(autoLinkOptions);
+    const cache = cacheStore.get(host);
+    const applicableCache = getApplicableLinkCache(cache, fullPreTextContent);
+    const hasCache = cache && applicableCache;
+    if (iteration > 0 && !hasCache) {
+      throw Error('Each iteration should set cache!');
+    }
+
+    let alreadyAutoLinked = hasCache ? applicableCache.processedText : '';
+    let toBeAutoLinked = hasCache
+      ? applicableCache.addedText
+      : fullPreTextContent;
+    let moreWorkToBeDone: boolean;
+
+    const budget = 10;
     let linkedNodes: (HTMLAnchorElement | Text)[];
-    if (sanitizer.getAutolink?.() ?? true) {
-      const cache = getApplicableLinkCache(
-        cacheStore.get(host),
-        preTextContent
+    let elapsed: number;
+    let newRequest: number | undefined;
+
+    do {
+      // find first space (or equivalent) which follows a non-space character.
+      const breakIndex = toBeAutoLinked.search(/(?<=\S)\s/);
+
+      const before =
+        breakIndex === -1
+          ? toBeAutoLinked
+          : toBeAutoLinked.slice(0, breakIndex);
+      const after = breakIndex === -1 ? '' : toBeAutoLinked.slice(breakIndex);
+      const fragment = alreadyAutoLinked + before;
+      linkedNodes = incrementalAutoLink(
+        cacheStore,
+        options,
+        autoLinkOptions,
+        fragment
       );
-      if (cache) {
-        const { cachedNodes: fromCache, addedText } = cache;
-        const newAdditions = autolink(addedText, autoLinkOptions);
-        const lastInCache = fromCache[fromCache.length - 1];
-        const firstNewNode = newAdditions[0];
-
-        if (lastInCache instanceof Text && firstNewNode instanceof Text) {
-          const joiningNode = lastInCache;
-          joiningNode.data += firstNewNode.data;
-          linkedNodes = [
-            ...fromCache.slice(0, -1),
-            joiningNode,
-            ...newAdditions.slice(1)
-          ];
-        } else {
-          linkedNodes = [...fromCache, ...newAdditions];
-        }
+      alreadyAutoLinked = fragment;
+      toBeAutoLinked = after;
+      moreWorkToBeDone = toBeAutoLinked != '';
+      elapsed = performance.now() - start;
+      newRequest = Private.hasNewRenderingRequest(host);
+    } while (elapsed < budget && moreWorkToBeDone && !newRequest);
+
+    // Note: we set `keepExisting` to `true` in `IRenderMime.IRenderer`s which
+    // use this method to ensure that the previous node is not removed from DOM
+    // when new chunks of data comes from the stream.
+    if (linkedNodes.length === 1 && linkedNodes[0] instanceof Text) {
+      if (host.childNodes.length === 1 && host.childNodes[0] === pre) {
+        // no-op
       } else {
-        linkedNodes = autolink(preTextContent, autoLinkOptions);
+        replaceChangedNodes(host, pre);
       }
+    } else {
+      // Persist nodes in cache by cloning them
       cacheStore.set(host, {
-        preTextContent,
+        preTextContent: alreadyAutoLinked,
         // Clone the nodes before storing them in the cache in case if another component
         // attempts to modify (e.g. dispose of) them - which is the case for search highlights!
         linkedNodes: linkedNodes.map(
           node => node.cloneNode(true) as HTMLAnchorElement | Text
         )
       });
+
+      const preNodes = Array.from(pre.cloneNode(true).childNodes) as (
+        | Text
+        | HTMLSpanElement
+      )[];
+      const node = mergeNodes(preNodes, [
+        ...linkedNodes,
+        document.createTextNode(toBeAutoLinked)
+      ]);
+
+      replaceChangedNodes(host, node);
+    }
+
+    // Continue unless:
+    // - no more text needs to be linkified,
+    // - new stream part was received (and new request sent),
+    // - maximum iterations limit was exceeded,
+    if (moreWorkToBeDone && !newRequest && iteration < maxIterations) {
+      return continueRendering();
     } else {
-      linkedNodes = [document.createTextNode(content)];
+      return stopRendering();
+    }
+  };
+
+  Private.scheduleRendering(host, renderFrame);
+}
+
+interface ISelectionOffsets {
+  processedCharacters: number;
+  anchor: number | null;
+  focus: number | null;
+}
+
+function computeSelectionCharacterOffset(
+  root: Node,
+  selection: Selection
+): ISelectionOffsets {
+  let anchor: number | null = null;
+  let focus: number | null = null;
+  let offset = 0;
+  for (const node of [...root.childNodes]) {
+    if (node === selection.focusNode) {
+      focus = offset + selection.focusOffset;
+    }
+    if (node === selection.anchorNode) {
+      anchor = offset + selection.anchorOffset;
+    }
+    if (node.childNodes.length > 0) {
+      const result = computeSelectionCharacterOffset(node, selection);
+      if (result.anchor) {
+        anchor = offset + result.anchor;
+      }
+      if (result.focus) {
+        focus = offset + result.focus;
+      }
+      offset += result.processedCharacters;
+    } else {
+      offset += node.textContent!.length;
+    }
+    if (anchor && focus) {
+      break;
+    }
+  }
+  return {
+    processedCharacters: offset,
+    anchor,
+    focus
+  };
+}
+
+function findTextSelectionNode(
+  root: Node,
+  textOffset: number | null,
+  offset: number
+) {
+  if (textOffset !== null) {
+    for (const node of [...root.childNodes]) {
+      // As much as possible avoid calling `textContent` here as it will cause layout invalidation
+      const nodeEnd =
+        node instanceof Text
+          ? node.nodeValue!.length
+          : (node instanceof HTMLAnchorElement
+              ? node.childNodes[0].nodeValue?.length ?? node.textContent?.length
+              : node.textContent?.length) ?? 0;
+      if (textOffset > offset && textOffset < offset + nodeEnd) {
+        if (node instanceof Text) {
+          return { node, positionOffset: textOffset - offset };
+        } else {
+          return findTextSelectionNode(node, textOffset, offset);
+        }
+      } else {
+        offset += nodeEnd;
+      }
     }
+  }
+  return {
+    node: null,
+    positionOffset: null
+  };
+}
+
+function selectByOffsets(
+  root: Node,
+  selection: Selection,
+  offsets: ISelectionOffsets
+) {
+  const { node: focusNode, positionOffset: focusOffset } =
+    findTextSelectionNode(root, offsets.focus, 0);
+  const { node: anchorNode, positionOffset: anchorOffset } =
+    findTextSelectionNode(root, offsets.anchor, 0);
+  if (
+    anchorNode &&
+    focusNode &&
+    anchorOffset !== null &&
+    focusOffset !== null
+  ) {
+    selection.setBaseAndExtent(
+      anchorNode,
+      anchorOffset,
+      focusNode,
+      focusOffset
+    );
+  }
+}
 
-    const preNodes = Array.from(pre.childNodes) as (Text | HTMLSpanElement)[];
-    ret = mergeNodes(preNodes, linkedNodes);
+function replaceChangedNodes(host: HTMLElement, node: HTMLPreElement) {
+  const result = checkChangedNodes(host, node);
+  const selection = window.getSelection();
+  const hasSelection = selection && selection.containsNode(host, true);
+  const selectionOffsets = hasSelection
+    ? computeSelectionCharacterOffset(host, selection)
+    : null;
+  const pre = result ? result.parent : node;
+  if (result) {
+    for (const element of result.toDelete) {
+      result.parent.removeChild(element);
+    }
+    result.parent.append(...result.toAppend);
   } else {
-    ret = document.createElement('pre');
+    host.replaceChildren(node);
+  }
+  // Restore selection - if there is a meaningful one.
+  if (selection && selectionOffsets) {
+    selectByOffsets(pre, selection, selectionOffsets);
+  }
+}
+
+/**
+ * Find nodes in `node` which do not have the same content or type and thus need to be appended.
+ */
+function checkChangedNodes(host: HTMLElement, node: HTMLPreElement) {
+  const oldPre = host.childNodes[0];
+  if (!oldPre) {
+    return;
   }
+  if (!(oldPre instanceof HTMLPreElement)) {
+    return;
+  }
+  // this should be cheap as the new node is not in dom yet,. right?
+  node.normalize();
+  const newNodes = node.childNodes;
+  const oldNodes = oldPre.childNodes;
 
-  host.appendChild(ret);
+  if (
+    // this could be generalized to appending a mix of text and non-text to a block of text too
+    // but for now handles the most common case of just streaming text
+    newNodes.length === 1 &&
+    newNodes[0] instanceof Text &&
+    [...oldNodes].every(n => n instanceof Text) &&
+    node.textContent!.startsWith(oldPre.textContent!)
+  ) {
+    const textNodeToAppend = document.createTextNode(
+      node.textContent!.slice(oldPre.textContent!.length)
+    );
+    return {
+      parent: oldPre,
+      toDelete: [],
+      toAppend: [textNodeToAppend]
+    };
+  }
+
+  let lastSharedNode: number = -1;
+  for (let i = 0; i < oldNodes.length; i++) {
+    const oldChild = oldNodes[i];
+    const newChild = newNodes[i];
+    if (
+      newChild &&
+      oldChild.nodeType === newChild.nodeType &&
+      oldChild.textContent === newChild.textContent
+    ) {
+      lastSharedNode = i;
+    } else {
+      break;
+    }
+  }
+
+  if (lastSharedNode === -1) {
+    return;
+  }
+  return {
+    parent: oldPre,
+    toDelete: [...oldNodes].slice(lastSharedNode),
+    toAppend: [...newNodes].slice(lastSharedNode)
+  };
+}
+
+function incrementalAutoLink(
+  cacheStore: WeakMap,
+  options: renderText.IRenderOptions,
+  autoLinkOptions: IAutoLinkOptions,
+  preFragmentToAutoLink: string
+): (HTMLAnchorElement | Text)[] {
+  const { host } = options;
+
+  // Note: only text nodes and span elements should be present after sanitization in the `
` element.
+  let linkedNodes: (HTMLAnchorElement | Text)[];
+
+  const cache = getApplicableLinkCache(
+    cacheStore.get(host),
+    preFragmentToAutoLink
+  );
+  if (cache) {
+    const { cachedNodes: fromCache, addedText } = cache;
+    const newAdditions = autolink(addedText, autoLinkOptions);
+    const lastInCache = fromCache[fromCache.length - 1];
+    const firstNewNode = newAdditions[0];
+
+    if (lastInCache instanceof Text && firstNewNode instanceof Text) {
+      const joiningNode = lastInCache;
+      joiningNode.data += firstNewNode.data;
+      linkedNodes = [
+        ...fromCache.slice(0, -1),
+        joiningNode,
+        ...newAdditions.slice(1)
+      ];
+    } else {
+      linkedNodes = [...fromCache, ...newAdditions];
+    }
+  } else {
+    linkedNodes = autolink(preFragmentToAutoLink, autoLinkOptions);
+  }
+  cacheStore.set(host, {
+    preTextContent: preFragmentToAutoLink,
+    linkedNodes
+  });
+  return linkedNodes;
 }
 
 /**
@@ -947,6 +1300,7 @@ function getApplicableLinkCache(
 ): {
   cachedNodes: IAutoLinkCacheEntry['linkedNodes'];
   addedText: string;
+  processedText: string;
 } | null {
   if (!cachedResult) {
     return null;
@@ -960,6 +1314,7 @@ function getApplicableLinkCache(
   let cachedNodes = cachedResult.linkedNodes;
   const lastCachedNode =
     cachedResult.linkedNodes[cachedResult.linkedNodes.length - 1];
+  let processedText = cachedResult.preTextContent;
 
   // Only use cached nodes if:
   // - the last cached node is a text node
@@ -980,6 +1335,12 @@ function getApplicableLinkCache(
     // we need to pass `bbb www.` + `two.com` through linkify again.
     cachedNodes = cachedNodes.slice(0, -1);
     addedText = lastCachedNode.textContent + addedText;
+    processedText = processedText.slice(0, -lastCachedNode.textContent!.length);
+  } else if (lastCachedNode instanceof HTMLAnchorElement) {
+    // TODO: why did I not include this condition before?
+    cachedNodes = cachedNodes.slice(0, -1);
+    addedText = lastCachedNode.textContent + addedText;
+    processedText = processedText.slice(0, -lastCachedNode.textContent!.length);
   } else {
     return null;
   }
@@ -990,7 +1351,8 @@ function getApplicableLinkCache(
   }
   return {
     cachedNodes,
-    addedText
+    addedText,
+    processedText
   };
 }
 
@@ -1128,14 +1490,103 @@ export namespace renderError {
  * The namespace for module implementation details.
  */
 namespace Private {
+  let lastFrameTimestamp: number | null = null;
+
+  /**
+   * Check if frame rendering can proceed in frame identified by timestamp
+   * from the first `requestAnimationFrame` callback argument. This argument
+   * is guaranteed to be the same for multiple requests executed on the same
+   * frame, which allows to limit number of animations to one per frame,
+   * and in turn avoids choking the rendering pipeline by creating tasks
+   * longer than the delta between frames.
+   *
+   * Also, we want to distribute the rendering between outputs to avoid
+   * displaying blank space while waiting for the previous output to be fully rendered.
+   */
+  export function canRenderInFrame(
+    timestamp: number,
+    host: HTMLElement
+  ): boolean {
+    if (lastFrameTimestamp !== timestamp) {
+      // progress queue
+      const last = renderQueue.shift();
+      if (last) {
+        renderQueue.push(last);
+      } else {
+        throw Error('Render queue cannot be empty here!');
+      }
+      lastFrameTimestamp = timestamp;
+    }
+    // check queue
+    if (renderQueue[0] === host) {
+      return true;
+    }
+    return false;
+  }
+
+  const renderQueue = new Array();
+  const frameRequests = new WeakMap();
+
+  export function cancelRenderingRequest(host: HTMLElement) {
+    // do not remove from queue - the expectation is that rendering will resume
+    const previousRequest = frameRequests.get(host);
+    if (previousRequest) {
+      window.cancelAnimationFrame(previousRequest);
+    }
+  }
+
+  export function scheduleRendering(
+    host: HTMLElement,
+    render: (timetamp: number) => void
+  ) {
+    // push at the end of the queue
+    if (!renderQueue.includes(host)) {
+      renderQueue.push(host);
+    }
+    const thisRequest = window.requestAnimationFrame(render);
+    frameRequests.set(host, thisRequest);
+  }
+
+  export function beginRendering(host: HTMLElement) {
+    frameRequests.delete(host);
+  }
+
+  export function removeFromQueue(host: HTMLElement) {
+    const index = renderQueue.indexOf(host);
+    if (index !== -1) {
+      renderQueue.splice(index, 1);
+    }
+  }
+
+  export function hasNewRenderingRequest(host: HTMLElement) {
+    return frameRequests.get(host);
+  }
+
   /**
    * Cache for auto-linking results to provide better performance when streaming outputs.
    */
-  export const autoLinkCache = new Map<
+  const autoLinkCache = new Map<
     string,
     WeakMap
   >();
 
+  export function getCacheStore(autoLinkOptions: IAutoLinkOptions) {
+    const cacheStoreOptions = [];
+    if (autoLinkOptions.checkWeb) {
+      cacheStoreOptions.push('web');
+    }
+    if (autoLinkOptions.checkPaths) {
+      cacheStoreOptions.push('paths');
+    }
+    const cacheStoreKey = cacheStoreOptions.join('-');
+    let cacheStore = autoLinkCache.get(cacheStoreKey);
+    if (!cacheStore) {
+      cacheStore = new WeakMap();
+      autoLinkCache.set(cacheStoreKey, cacheStore);
+    }
+    return cacheStore;
+  }
+
   /**
    * Eval the script tags contained in a host populated by `innerHTML`.
    *
diff --git a/packages/rendermime/src/widgets.ts b/packages/rendermime/src/widgets.ts
index 78eb93aa55cf..fa4041cbe141 100644
--- a/packages/rendermime/src/widgets.ts
+++ b/packages/rendermime/src/widgets.ts
@@ -93,7 +93,7 @@ export abstract class RenderedCommon
     // TODO compare model against old model for early bail?
 
     // Empty any existing content in the node from previous renders
-    if (!keepExisting) {
+    if (!keepExisting && !this.keepExisting) {
       while (this.node.firstChild) {
         this.node.removeChild(this.node.firstChild);
       }
@@ -121,6 +121,8 @@ export abstract class RenderedCommon
    */
   abstract render(model: IRenderMime.IMimeModel): Promise;
 
+  keepExisting?: boolean = false;
+
   /**
    * Set the URI fragment identifier.
    *
@@ -426,6 +428,7 @@ export class RenderedText extends RenderedCommon {
     super(options);
     this.addClass('jp-RenderedText');
   }
+  keepExisting = true;
 
   /**
    * Render a mime model.
@@ -449,6 +452,7 @@ export class RenderedError extends RenderedCommon {
     super(options);
     this.addClass('jp-RenderedText');
   }
+  keepExisting = true;
 
   render(model: IRenderMime.IMimeModel): Promise {
     return renderers.renderError({