Skip to content
Draft
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 260 additions & 48 deletions packages/rendermime/src/renderers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,70 +836,183 @@
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 `<pre>` 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<HTMLElement, IAutoLinkCacheEntry>,
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 `<pre>` 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;
}

/**
Expand Down Expand Up @@ -947,6 +1060,7 @@
): {
cachedNodes: IAutoLinkCacheEntry['linkedNodes'];
addedText: string;
processedText: string;
} | null {
if (!cachedResult) {
return null;
Expand All @@ -960,6 +1074,7 @@
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
Expand All @@ -980,6 +1095,12 @@
// 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;
}
Expand All @@ -990,7 +1111,8 @@
}
return {
cachedNodes,
addedText
addedText,
processedText
};
}

Expand Down Expand Up @@ -1128,14 +1250,104 @@
* 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<HTMLElement>();
const frameRequests = new WeakMap<HTMLElement, number>();

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<HTMLElement, IAutoLinkCacheEntry>
>();

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`.
*
Expand Down
Loading