Skip to content

Commit

Permalink
Improve performance of rendering stdout/stderr (jupyterlab#17022)
Browse files Browse the repository at this point in the history
* Add a test for a fast rendering path when no ANSI codes are present

* Expect the fast path to be at least 10% faster

* Rewrite the test to use sanitizer spy instead of timing

* Implement fast path for escaping HTML when ANSI is absent

Ignore eslint false positive
  • Loading branch information
krassowski authored Dec 3, 2024
1 parent c9700a2 commit a0733bf
Show file tree
Hide file tree
Showing 2 changed files with 65 additions and 4 deletions.
27 changes: 23 additions & 4 deletions packages/rendermime/src/renderers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,19 @@ export function renderText(options: renderText.IRenderOptions): Promise<void> {
return Promise.resolve(undefined);
}

/**
* Sanitize HTML out using native browser sanitizer.
*
* Compared to the `ISanitizer.sanitize` this does not allow to selectively
* allow to keep certain tags but escapes everything; on the other hand
* it is much faster as it uses platform-optimized code.
*/
function nativeSanitize(source: string): string {
const el = document.createElement('span');
el.textContent = source;
return el.innerHTML;
}

/**
* Render the textual representation into a host node.
*
Expand All @@ -808,10 +821,16 @@ function renderTextual(
// Unpack the options.
const { host, sanitizer, source } = options;

// Create the HTML content.
const content = sanitizer.sanitize(Private.ansiSpan(source), {
allowedTags: ['span']
});
const ansiPrefixRe = /\x1b/; // eslint-disable-line no-control-regex
const hasAnsiPrefix = ansiPrefixRe.test(source);

// 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.
const pre = document.createElement('pre');
Expand Down
42 changes: 42 additions & 0 deletions packages/rendermime/test/factories.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,48 @@ describe('rendermime/factories', () => {
expect(end - start).toBeLessThan(timeout);
});

it('should use a fast path when no ANSI codes are present', async () => {
const mimeType = 'application/vnd.jupyter.stderr';

const ansiEscape = '\x1b[01;41;32mtext\x1b[00m';
const notAnsiEscape = '\x1a[01;41;32mtext\x1a[00m';

// We cannot just compare times here because:
// a) tests are run in jsdom thus "native" sanitizer is not much faster
// b) `Private.ansiSpan` has much higher cost when ANSI escapes are present

const testSource = '<script>window.x = 1</script>';
const spy = jest.spyOn(sanitizer, 'sanitize');

// Initialize slow path scenario
let model = createModel(mimeType, testSource + ansiEscape);
let w = errorRendererFactory.createRenderer({ mimeType, ...options });
expect(spy).toHaveBeenCalledTimes(0);

// Test slow path
await w.renderModel(model);
// Sanitizer.sanitize should have been called
expect(spy).toHaveBeenCalledTimes(1);

const escapedSlow = w.node.querySelector('pre')!.innerHTML;

// Initialize fast path scenario.
model = createModel(mimeType, testSource + notAnsiEscape);
w = errorRendererFactory.createRenderer({ mimeType, ...options });

// Test fast path
await w.renderModel(model);
// Sanitizer.sanitize should not have been called
expect(spy).toHaveBeenCalledTimes(1);

const escapedFast = w.node.querySelector('pre')!.innerHTML;

// Disregarding the suffix the escaped code should be the same.
expect(escapedFast.slice(0, testSource.length)).toEqual(
escapedSlow.slice(0, testSource.length)
);
});

it.each([
['arrives in a new line', 'www.example.com', '\n a new line of text'],
['arrives after a new line', 'www.example.com\n', 'a new line of text'],
Expand Down

0 comments on commit a0733bf

Please sign in to comment.