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({