diff --git a/packages/rendermime/src/renderers.ts b/packages/rendermime/src/renderers.ts index b608c723fdd0..8497b4a69329 100644 --- a/packages/rendermime/src/renderers.ts +++ b/packages/rendermime/src/renderers.ts @@ -836,70 +836,183 @@ function renderTextual( const pre = document.createElement('pre'); pre.innerHTML = content; - const preTextContent = pre.textContent; + const fullPreTextContent = pre.textContent; - 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); + if (!fullPreTextContent) { + // Short-circuit if there is not content to auto-link + host.appendChild(pre); + return; } - let ret: HTMLPreElement; - if (preTextContent) { - // Note: only text nodes and span elements should be present after sanitization in the `
` element.
+  // 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.abortRendering(host);
+
+  // Stop rendering after 10 minutes (assuming 60 Hz)
+  const maxIterations = 60 * 60 * 10;
+  let iteration = 0;
+
+  const renderFrame = (timestamp: number) => {
+    // Skip rendering in this frame if the output is not visible was (temporarily) removed from DOM
+    // - is hidden due to scrolling away in full windowed notebook mode) - TODO
+    // Note: cannot use checkVisibility as this triggers layout (unless we only use setTimeout to delay trashing?)
+    if (!host.isConnected || !Private.canRenderInFrame(timestamp, host)) {
+      // || !host.checkVisibility()) {
+      Private.scheduleRendering(host, renderFrame);
+      return;
+    }
+
+    const start = performance.now();
+
+    Private.beginRendering(host);
+    const shouldAutoLink = sanitizer.getAutolink?.() ?? true;
+
+    if (!shouldAutoLink) {
+      host.replaceChildren(pre.cloneNode(true));
+      // host.replaceChildren(pre);
+      //TODO TEST, is cloning needed
+      return;
+    }
+    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 = true;
+
+    const budget = 13;
     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);
+      //console.debug({elapsed, moreWorkToBeDone, fragment, breakIndex, iteration, newRequest});
+    } while (elapsed < budget && moreWorkToBeDone && !newRequest);
+
+    // TODO: because keepExisting=False in renderModel, the pre node gets
+    // cleared as new data comes in, but there is a substantial delay before it reappears;
+    // how to ensure it gets rendered promptly?
+    if (linkedNodes.length === 1 && linkedNodes[0] instanceof Text) {
+      if (host.childNodes.length === 1 && host.childNodes[0] === pre) {
+        // no-op
       } else {
-        linkedNodes = autolink(preTextContent, autoLinkOptions);
+        setTimeout(() => {
+          //console.log(pre)
+          // Do not perform DOM manipulation within requestAnimationFrame callback
+          // as this would result in layout trashing, instead push it to just after
+          host.replaceChildren(pre); //.cloneNode(true)); //);
+        });
       }
+    } 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
         )
       });
-    } else {
-      linkedNodes = [document.createTextNode(content)];
+
+      const preNodes = Array.from(pre.cloneNode(true).childNodes) as (
+        | Text
+        | HTMLSpanElement
+      )[];
+      const node = mergeNodes(preNodes, [
+        ...linkedNodes,
+        document.createTextNode(toBeAutoLinked)
+      ]);
+      //console.warn({status: 'rendering', toBeAutoLinked, node, preNodes, linkedNodes})
+      setTimeout(() => {
+        // Do not perform DOM manipulation within requestAnimationFrame callback
+        // as this would result in layout trashing, instead push it to just after
+        host.replaceChildren(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) {
+      iteration += 1;
+      Private.scheduleRendering(host, renderFrame);
     }
+  };
+
+  Private.scheduleRendering(host, renderFrame);
+}
+
+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 preNodes = Array.from(pre.childNodes) as (Text | HTMLSpanElement)[];
-    ret = mergeNodes(preNodes, linkedNodes);
+  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 {
-    ret = document.createElement('pre');
+    linkedNodes = autolink(preFragmentToAutoLink, autoLinkOptions);
   }
-
-  host.appendChild(ret);
+  cacheStore.set(host, {
+    preTextContent: preFragmentToAutoLink,
+    linkedNodes
+  });
+  return linkedNodes;
 }
 
 /**
@@ -947,6 +1060,7 @@ function getApplicableLinkCache(
 ): {
   cachedNodes: IAutoLinkCacheEntry['linkedNodes'];
   addedText: string;
+  processedText: string;
 } | null {
   if (!cachedResult) {
     return null;
@@ -960,6 +1074,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 +1095,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 +1111,8 @@ function getApplicableLinkCache(
   }
   return {
     cachedNodes,
-    addedText
+    addedText,
+    processedText
   };
 }
 
@@ -1128,14 +1250,104 @@ 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 abortRendering(host: HTMLElement) {
+    const previousRequest = frameRequests.get(host);
+    if (previousRequest) {
+      window.cancelAnimationFrame(previousRequest);
+    }
+    //removeFromQueue(host);
+  }
+
+  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);
+    removeFromQueue(host);
+  }
+
+  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`.
    *