From cf9ca3559c9efbe21734502e37a41a8a39078776 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:47:35 +0000 Subject: [PATCH 01/14] Add performance and caching tests for stdout/stderr --- packages/rendermime/test/factories.spec.ts | 160 ++++++++++++++++++++- 1 file changed, 158 insertions(+), 2 deletions(-) diff --git a/packages/rendermime/test/factories.spec.ts b/packages/rendermime/test/factories.spec.ts index ffe1e59dd108..51f3be7714e0 100644 --- a/packages/rendermime/test/factories.spec.ts +++ b/packages/rendermime/test/factories.spec.ts @@ -570,6 +570,162 @@ describe('rendermime/factories', () => { ); }); + it.each([ + // Note: timeouts are set to 3.5 times more the local performance to allow for slower runs on CI + // + // Local benchmarks: + // - without linkify cache: 12.5s + // - with cache: 1.1s + [ + 'when new content arrives line by line', + '\n' + 'X'.repeat(5000), + 1100 * 3.5 + ], + // Local benchmarks: + // - without cache: 3.8s + // - with cache: 0.8s + [ + 'when new content is added to the same line', + 'test.com ' + 'X'.repeat(2500) + ' www.', + 800 * 3.5 + ] + ])('should be fast %s', async (_, newContent, timeout) => { + let source = ''; + const mimeType = 'application/vnd.jupyter.stderr'; + + const model = createModel(mimeType, source); + const w = errorRendererFactory.createRenderer({ mimeType, ...options }); + + const start = performance.now(); + for (let i = 0; i < 25; i++) { + source += newContent; + model.setData({ + data: { + [mimeType]: source + } + }); + await w.renderModel(model); + } + const end = performance.now(); + + expect(end - start).toBeLessThan(timeout); + }); + + 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'], + ['arrives after a text node', 'www.example.com next line', ' of text'], + ['arrives after a text node', 'www.example.com\nnext line', ' of text'] + ])( + 'should use cached links if new content %s', + async (_, oldSource, addition) => { + const mimeType = 'application/vnd.jupyter.stderr'; + let source = oldSource; + const model = createModel(mimeType, source); + const w = errorRendererFactory.createRenderer({ + mimeType, + ...defaultOptions + }); + // Perform an initial render to populate the cache. + await w.renderModel(model); + const before = w.node.innerHTML; + const cachedLink = w.node.querySelector('a'); + expect(cachedLink).toBe(w.node.childNodes[0].childNodes[0]); + + // Update the source. + source += addition; + model.setData({ + data: { + [mimeType]: source + } + }); + + // Perform a second render which should use the cache. + await w.renderModel(model); + const after = w.node.innerHTML; + const linkAfter = w.node.querySelector('a'); + + // The contents of the node should be updated with the new line. + expect(before).not.toEqual(after); + expect(after).toContain('line of text'); + + // If cached links were used, the anchor nodes will be reused. + expect(cachedLink).not.toBe(null); + expect(cachedLink).toBe(w.node.childNodes[0].childNodes[0]); + expect(cachedLink).toBe(linkAfter); + } + ); + + it('should not use cached links if the new content appends to the link', async () => { + const mimeType = 'application/vnd.jupyter.stderr'; + let source = 'www.example.co'; + const model = createModel(mimeType, source); + const w = errorRendererFactory.createRenderer({ + mimeType, + ...defaultOptions + }); + // Perform an initial render to populate the cache. + await w.renderModel(model); + const before = w.node.innerHTML; + const cachedLink = w.node.querySelector('a'); + + // Update the source. + source += 'm'; + model.setData({ + data: { + [mimeType]: source + } + }); + + // Perform a second render. + await w.renderModel(model); + const after = w.node.innerHTML; + const linkAfter = w.node.querySelector('a'); + + // The contents of the node should be updated with the new line. + expect(before).not.toEqual(after); + + // If cached links were used, the anchor nodes will be reused. + expect(cachedLink).not.toBe(null); + expect(cachedLink).not.toBe(linkAfter); + }); + + it('should use partial cache if a link is created by addition of a new fragment', async () => { + const mimeType = 'application/vnd.jupyter.stderr'; + let source = 'aaa www.one.com bbb www.'; + const model = createModel(mimeType, source); + const w = errorRendererFactory.createRenderer({ + mimeType, + ...defaultOptions + }); + // Perform an initial render to populate the cache. + await w.renderModel(model); + const cachedTextNode = w.node.childNodes[0].childNodes[0]; + const linksBefore = w.node.querySelectorAll('a'); + expect(linksBefore).toHaveLength(1); + + // Update the source. + source += 'two.com'; + model.setData({ + data: { + [mimeType]: source + } + }); + + // Perform a second render. + await w.renderModel(model); + const textNodeAfter = w.node.childNodes[0].childNodes[0]; + const linksAfter = w.node.querySelectorAll('a'); + + // It should not use the second text node (`bbb www.`) from cache and instead + // it should fragment properly linkify the second link + expect(linksAfter).toHaveLength(2); + + // If cached nodes were used, the text nodes will be reused. + expect(cachedTextNode).toBeInstanceOf(Text); + expect(cachedTextNode).toBe(textNodeAfter); + }); + it('should autolink a single known file path', async () => { const f = errorRendererFactory; const urls = [ @@ -642,10 +798,10 @@ describe('rendermime/factories', () => { const source = 'www.example.com'; const expected = '
www.example.com'; - const f = textRendererFactory; + const f = errorRendererFactory; const mimeType = 'application/vnd.jupyter.stderr'; const model = createModel(mimeType, source); - const w = f.createRenderer({ mimeType, ...options }); + const w = f.createRenderer({ mimeType, ...defaultOptions }); await w.renderModel(model); expect(w.node.innerHTML).toBe(expected); }); From 4422598f845ce1f27ffc045442632e6aaa27d82d Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:27:00 +0000 Subject: [PATCH 02/14] Add linkification cache for stdout/stderr rendering --- packages/rendermime/src/renderers.ts | 177 +++++++++++++++++++++------ 1 file changed, 139 insertions(+), 38 deletions(-) diff --git a/packages/rendermime/src/renderers.ts b/packages/rendermime/src/renderers.ts index 70bd3489f6d0..0e9652a66ad4 100644 --- a/packages/rendermime/src/renderers.ts +++ b/packages/rendermime/src/renderers.ts @@ -787,6 +787,24 @@ function* alignedNodes
` element.
- const linkedNodes =
- sanitizer.getAutolink?.() ?? true
- ? autolink(preTextContent, {
- checkWeb: true,
- checkPaths: false
- })
- : [document.createTextNode(content)];
+ let linkedNodes: (HTMLAnchorElement | Text)[];
+ if (sanitizer.getAutolink?.() ?? true) {
+ const cache = getApplicableLinkCache(
+ cacheStore.get(host),
+ preTextContent
+ );
+ if (cache) {
+ const { cachedNodes, addedText } = cache;
+ const fromCache = cachedNodes;
+ 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(preTextContent, autoLinkOptions);
+ }
+ cacheStore.set(host, {
+ preTextContent,
+ linkedNodes
+ });
+ } else {
+ linkedNodes = [document.createTextNode(content)];
+ }
const preNodes = Array.from(pre.childNodes) as (Text | HTMLSpanElement)[];
ret = mergeNodes(preNodes, linkedNodes);
@@ -819,9 +878,6 @@ export function renderText(options: renderText.IRenderOptions): Promise {
}
host.appendChild(ret);
-
- // Return the rendered promise.
- return Promise.resolve(undefined);
}
/**
@@ -854,6 +910,67 @@ export namespace renderText {
}
}
+interface IAutoLinkCacheEntry {
+ preTextContent: string;
+ linkedNodes: (HTMLAnchorElement | Text)[];
+}
+
+/**
+ * Return the information from the cache that can be used given the cache entry and current text.
+ * If the cache is invalid given the current text (or cannot be used) `null` is returned.
+ */
+function getApplicableLinkCache(
+ cachedResult: IAutoLinkCacheEntry | undefined,
+ preTextContent: string
+): {
+ cachedNodes: IAutoLinkCacheEntry['linkedNodes'];
+ addedText: string;
+} | null {
+ if (!cachedResult) {
+ return null;
+ }
+ if (preTextContent.length < cachedResult.preTextContent.length) {
+ // If the new content is shorter than the cached content
+ // we cannot use the cache as we only support appending.
+ return null;
+ }
+ let addedText = preTextContent.substring(cachedResult.preTextContent.length);
+ let cachedNodes = cachedResult.linkedNodes;
+ const lastCachedNode =
+ cachedResult.linkedNodes[cachedResult.linkedNodes.length - 1];
+
+ // Only use cached nodes if:
+ // - the last cached node is a text node
+ // - the new content starts with a new line
+ // - the old content ends with a new line
+ if (
+ cachedResult.preTextContent.endsWith('\n') ||
+ addedText.startsWith('\n')
+ ) {
+ // continue
+ } else if (lastCachedNode instanceof Text) {
+ // Remove the Text node to re-analyse this text.
+ // This is required when we cached `aaa www.one.com bbb www.`
+ // and the incoming addition is `two.com`. We can still
+ // use text node `aaa ` and anchor node `www.one.com`, but
+ // we need to pass `bbb www.` + `two.com` through linkify again.
+ cachedNodes = cachedNodes.slice(0, -1);
+ addedText = lastCachedNode.textContent + addedText;
+ // continue
+ } else {
+ return null;
+ }
+
+ // Finally check if text has not changed.
+ if (!preTextContent.startsWith(cachedResult.preTextContent)) {
+ return null;
+ }
+ return {
+ cachedNodes,
+ addedText
+ };
+}
+
/**
* Render error into a host node.
*
@@ -865,37 +982,13 @@ export function renderError(
options: renderError.IRenderOptions
): Promise {
// Unpack the options.
- const { host, linkHandler, sanitizer, resolver, source } = options;
+ const { host, linkHandler, resolver } = options;
- // Create the HTML content.
- const content = sanitizer.sanitize(Private.ansiSpan(source), {
- allowedTags: ['span']
+ renderTextual(options, {
+ checkWeb: true,
+ checkPaths: true
});
- // Set the sanitized content for the host node.
- const pre = document.createElement('pre');
- pre.innerHTML = content;
-
- const preTextContent = pre.textContent;
-
- let ret: HTMLPreElement;
- if (preTextContent) {
- // Note: only text nodes and span elements should be present after sanitization in the `` element.
- const linkedNodes =
- sanitizer.getAutolink?.() ?? true
- ? autolink(preTextContent, {
- checkWeb: true,
- checkPaths: true
- })
- : [document.createTextNode(content)];
-
- const preNodes = Array.from(pre.childNodes) as (Text | HTMLSpanElement)[];
- ret = mergeNodes(preNodes, linkedNodes);
- } else {
- ret = document.createElement('pre');
- }
- host.appendChild(ret);
-
// Patch the paths if a resolver is available.
let promise: Promise;
if (resolver) {
@@ -1012,6 +1105,14 @@ export namespace renderError {
* The namespace for module implementation details.
*/
namespace Private {
+ /**
+ * Cache for auto-linking results to provide better performance when streaming outputs.
+ */
+ export const autoLinkCache = new Map<
+ string,
+ WeakMap
+ >();
+
/**
* Eval the script tags contained in a host populated by `innerHTML`.
*
From e9e2b820fede3b19b3e3a0442f13836d83ba3192 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Krassowski?=
<5832902+krassowski@users.noreply.github.com>
Date: Wed, 20 Nov 2024 09:10:59 +0000
Subject: [PATCH 03/14] Run CI on Python 3.9 and 3.13 (drop 3.8 from testing
matrix) (#16852)
* Run CI on Python 3.9 and 3.13 (drop 3.8 from testing matrix)
* Bump `setuptools` to fix minimum versions test.
For reference it was:
The conflict is caused by:
The user requested setuptools>=40.8.0
The user requested (constraint) setuptools==40.1.0
* Try `setuptools>=40.9.0`
* Setuptools 41.1.0 is minimum that works with Python 3.9
See https://github.com/pypa/setuptools/blob/e622859e278e1751175ded6f8f41ea3de06e4855/NEWS.rst#v4110
---
.github/workflows/linuxjs-tests.yml | 5 +----
.github/workflows/linuxtests.yml | 26 +++++++++++++-------------
.github/workflows/macostests.yml | 4 ++--
.github/workflows/windowstests.yml | 1 +
pyproject.toml | 2 +-
5 files changed, 18 insertions(+), 20 deletions(-)
diff --git a/.github/workflows/linuxjs-tests.yml b/.github/workflows/linuxjs-tests.yml
index 8fe4712b219f..bc649ec51f6a 100644
--- a/.github/workflows/linuxjs-tests.yml
+++ b/.github/workflows/linuxjs-tests.yml
@@ -13,10 +13,7 @@ jobs:
name: JS
strategy:
matrix:
- # Fix for https://github.com/jupyterlab/jupyterlab/issues/13903
- include:
- - group: js-debugger
- python-version: '3.11'
+ python-version: ["3.13"]
group:
[
js-application,
diff --git a/.github/workflows/linuxtests.yml b/.github/workflows/linuxtests.yml
index 3d4c346552c0..cb5feb71577a 100644
--- a/.github/workflows/linuxtests.yml
+++ b/.github/workflows/linuxtests.yml
@@ -15,7 +15,7 @@ jobs:
matrix:
group: [integrity, integrity2, integrity3, release_test, docs, usage, usage2, splice_source, python, examples, interop, nonode, lint]
# This will be used by the base setup action
- python-version: ["3.8", "3.12"]
+ python-version: ["3.9", "3.13"]
include:
- group: examples
upload-output: true
@@ -23,27 +23,27 @@ jobs:
upload-output: true
exclude:
- group: integrity
- python-version: "3.8"
+ python-version: "3.9"
- group: integrity2
- python-version: "3.8"
+ python-version: "3.9"
- group: integrity3
- python-version: "3.8"
+ python-version: "3.9"
- group: release_test
- python-version: "3.8"
+ python-version: "3.9"
- group: docs
- python-version: "3.8"
+ python-version: "3.9"
- group: usage
- python-version: "3.8"
+ python-version: "3.9"
- group: usage2
- python-version: "3.8"
+ python-version: "3.9"
- group: nonode
- python-version: "3.8"
+ python-version: "3.9"
- group: lint
- python-version: "3.8"
+ python-version: "3.9"
- group: examples
- python-version: "3.8"
+ python-version: "3.9"
- group: splice_source
- python-version: "3.8"
+ python-version: "3.9"
fail-fast: false
timeout-minutes: 45
runs-on: ubuntu-22.04
@@ -88,7 +88,7 @@ jobs:
- name: Base Setup
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
with:
- python_version: "3.8"
+ python_version: "3.9"
dependency_type: minimum
- name: Install dependencies
run: |
diff --git a/.github/workflows/macostests.yml b/.github/workflows/macostests.yml
index 9e7a38365339..ed5ebdc41178 100644
--- a/.github/workflows/macostests.yml
+++ b/.github/workflows/macostests.yml
@@ -14,10 +14,10 @@ jobs:
strategy:
matrix:
group: [integrity, python, usage, usage2]
- python-version: [3.11]
+ python-version: [3.12]
include:
- group: python
- python-version: 3.12
+ python-version: 3.13
fail-fast: false
timeout-minutes: 45
runs-on: macos-latest
diff --git a/.github/workflows/windowstests.yml b/.github/workflows/windowstests.yml
index e9142e7fa527..d1eb9af9c519 100644
--- a/.github/workflows/windowstests.yml
+++ b/.github/workflows/windowstests.yml
@@ -14,6 +14,7 @@ jobs:
strategy:
matrix:
group: [integrity, python]
+ python-version: ["3.13"]
fail-fast: false
runs-on: windows-latest
timeout-minutes: 40
diff --git a/pyproject.toml b/pyproject.toml
index 4f96a1f6bb75..1d19bc2df2ab 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,7 +47,7 @@ dependencies = [
"jupyterlab_server>=2.27.1,<3",
"notebook_shim>=0.2",
"packaging",
- "setuptools>=40.1.0",
+ "setuptools>=41.1.0",
"tomli>=1.2.2;python_version<\"3.11\"",
"tornado>=6.2.0",
"traitlets",
From f2ccf6b5f4e2282e1b66c2950005bf522c44a93f Mon Sep 17 00:00:00 2001
From: Nicolas Brichet <32258950+brichet@users.noreply.github.com>
Date: Thu, 21 Nov 2024 16:06:45 +0100
Subject: [PATCH 04/14] Restore viewport `min-height` when not windowing
(#16979)
---
packages/ui-components/src/components/windowedlist.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/ui-components/src/components/windowedlist.ts b/packages/ui-components/src/components/windowedlist.ts
index fd059bb8992e..782cb6ba3f86 100644
--- a/packages/ui-components/src/components/windowedlist.ts
+++ b/packages/ui-components/src/components/windowedlist.ts
@@ -1315,6 +1315,7 @@ export class WindowedList<
private _applyNoWindowingStyles() {
this._viewport.style.position = 'relative';
this._viewport.style.top = '0px';
+ this._viewport.style.minHeight = '';
this._innerElement.style.height = '';
}
/**
From 006a459698a01a007759316aa08c25fe40f70725 Mon Sep 17 00:00:00 2001
From: Jason Weill <93281816+JasonWeill@users.noreply.github.com>
Date: Thu, 21 Nov 2024 08:34:26 -0800
Subject: [PATCH 05/14] Drag image prompt styling (#16972)
* Revert "Alternate description for disabled filters"
This reverts commit 059fb7eb562defb8040cf1d624dbf0cee7f544e0.
* Revert "Revert "Alternate description for disabled filters""
This reverts commit 27ed1d0779c1dff9e78848a20669de4a06e88be2.
* Diminishes border color when dragging multiple cells
---------
Co-authored-by: Jeremy Tuloup
---
packages/notebook/style/base.css | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/packages/notebook/style/base.css b/packages/notebook/style/base.css
index 17be4dc7b1bb..c7b237541996 100644
--- a/packages/notebook/style/base.css
+++ b/packages/notebook/style/base.css
@@ -204,6 +204,7 @@
flex: 0 0 auto;
min-width: 36px;
color: var(--jp-cell-inprompt-font-color);
+ opacity: 0.5;
padding: var(--jp-code-padding);
padding-left: 12px;
font-family: var(--jp-cell-prompt-font-family);
@@ -221,7 +222,13 @@
top: 8px;
left: 8px;
background: var(--jp-layout-color2);
- border: var(--jp-border-width) solid var(--jp-input-border-color);
+ border-width: var(--jp-border-width);
+ border-style: solid;
+ border-color: color-mix(
+ in srgb,
+ var(--jp-input-border-color) 20%,
+ transparent
+ );
box-shadow: 2px 2px 4px 0 rgba(0, 0, 0, 0.12);
}
From ebf39b6c1e2bee35b32bb88992d05f0ddb7180db Mon Sep 17 00:00:00 2001
From: David Brochart
Date: Tue, 26 Nov 2024 14:20:07 +0100
Subject: [PATCH 06/14] Fix emission of `FileBrowserModel.onFileChanged` for
drives (including `RTC:`) (#16988)
* Fix FileBrowserModel.onFileChanged
* Fix
* Add back prefix before comparing paths
* Add test
---
packages/filebrowser/src/model.ts | 9 +++++++--
packages/filebrowser/test/model.spec.ts | 22 ++++++++++++++++++++++
2 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/packages/filebrowser/src/model.ts b/packages/filebrowser/src/model.ts
index 3d7832fbd7e0..6b3636b6327f 100644
--- a/packages/filebrowser/src/model.ts
+++ b/packages/filebrowser/src/model.ts
@@ -609,10 +609,15 @@ export class FileBrowserModel implements IDisposable {
const path = this._model.path;
const { sessions } = this.manager.services;
const { oldValue, newValue } = change;
+ const prefix = this.driveName.length > 0 ? this.driveName + ':' : '';
const value =
- oldValue && oldValue.path && PathExt.dirname(oldValue.path) === path
+ oldValue &&
+ oldValue.path &&
+ prefix + PathExt.dirname(oldValue.path) === path
? oldValue
- : newValue && newValue.path && PathExt.dirname(newValue.path) === path
+ : newValue &&
+ newValue.path &&
+ prefix + PathExt.dirname(newValue.path) === path
? newValue
: undefined;
diff --git a/packages/filebrowser/test/model.spec.ts b/packages/filebrowser/test/model.spec.ts
index 60f8fdd79227..dc3acbb4c5b2 100644
--- a/packages/filebrowser/test/model.spec.ts
+++ b/packages/filebrowser/test/model.spec.ts
@@ -152,6 +152,28 @@ describe('filebrowser/model', () => {
expect(called).toBe(true);
});
+ it('should be emitted when a file is created in a drive with a name', async () => {
+ await state.clear();
+ const driveName = 'RTC';
+ const modelWithName = new FileBrowserModel({
+ manager,
+ state,
+ driveName
+ });
+
+ let called = false;
+ modelWithName.fileChanged.connect((sender, args) => {
+ expect(sender).toBe(modelWithName);
+ expect(args.type).toBe('new');
+ expect(args.oldValue).toBeNull();
+ expect(args.newValue!.type).toBe('file');
+ called = true;
+ });
+ await manager.newUntitled({ type: 'file' });
+ expect(called).toBe(true);
+ modelWithName.dispose();
+ });
+
it('should be emitted when a file is renamed', async () => {
let called = false;
model.fileChanged.connect((sender, args) => {
From 948ec2231ab6c7bf0e608b903a9cd7b188ecfb2a Mon Sep 17 00:00:00 2001
From: Chiara Marmo
Date: Tue, 26 Nov 2024 14:53:56 +0100
Subject: [PATCH 07/14] Add forgotten bracket in code sample (#16998)
---
docs/source/extension/extension_points.rst | 1 +
1 file changed, 1 insertion(+)
diff --git a/docs/source/extension/extension_points.rst b/docs/source/extension/extension_points.rst
index eabb9cef579f..fe683ba5cc61 100644
--- a/docs/source/extension/extension_points.rst
+++ b/docs/source/extension/extension_points.rst
@@ -74,6 +74,7 @@ Here is a sample block of code that adds a command to the application (given by
execute: () => {
console.log(`Executed ${commandID}`);
toggled = !toggled;
+ }
});
This example adds a new command, which, when triggered, calls the ``execute`` function.
From d317b2e1b728578bbb6959a412e7be485c987110 Mon Sep 17 00:00:00 2001
From: David Brochart
Date: Tue, 26 Nov 2024 16:49:00 +0100
Subject: [PATCH 08/14] Fix handling of carriage return in output streams
(#16999)
* Fix handling of carriage return in output streams
* Lint
---
packages/outputarea/src/model.ts | 33 ++++++++++++++++++++++++--------
1 file changed, 25 insertions(+), 8 deletions(-)
diff --git a/packages/outputarea/src/model.ts b/packages/outputarea/src/model.ts
index a56ff05a68a7..72e1c66f363f 100644
--- a/packages/outputarea/src/model.ts
+++ b/packages/outputarea/src/model.ts
@@ -358,7 +358,7 @@ export class OutputAreaModel implements IOutputAreaModel {
const curText = prev.streamText!;
const newText =
typeof value.text === 'string' ? value.text : value.text.join('');
- Private.addText(curText, newText);
+ this._streamIndex = Private.addText(this._streamIndex, curText, newText);
return this.length;
}
@@ -366,7 +366,12 @@ export class OutputAreaModel implements IOutputAreaModel {
if (typeof value.text !== 'string') {
value.text = value.text.join('');
}
- value.text = Private.processText(value.text);
+ const { text, index } = Private.processText(
+ this._streamIndex,
+ value.text
+ );
+ this._streamIndex = index;
+ value.text = text;
}
// Create the new item.
@@ -480,6 +485,7 @@ export class OutputAreaModel implements IOutputAreaModel {
private _changed = new Signal(
this
);
+ private _streamIndex = 0;
}
/**
@@ -530,14 +536,20 @@ namespace Private {
/*
* Handle backspaces in `newText` and concatenates to `text`, if any.
*/
- export function processText(newText: string, text?: string): string {
+ export function processText(
+ index: number,
+ newText: string,
+ text?: string
+ ): { text: string; index: number } {
if (text === undefined) {
text = '';
}
if (!(newText.includes('\b') || newText.includes('\r'))) {
- return text + newText;
+ text =
+ text.slice(0, index) + newText + text.slice(index + newText.length);
+ return { text, index: index + newText.length };
}
- let idx0 = text.length;
+ let idx0 = index;
let idx1: number = -1;
let lastEnd: number = 0;
const regex = /[\n\b\r]/;
@@ -587,14 +599,18 @@ namespace Private {
throw Error(`This should not happen`);
}
}
- return text;
+ return { text, index: idx0 };
}
/*
* Concatenate a string to an observable string, handling backspaces.
*/
- export function addText(curText: IObservableString, newText: string): void {
- const text = processText(newText, curText.text);
+ export function addText(
+ prevIndex: number,
+ curText: IObservableString,
+ newText: string
+ ): number {
+ const { text, index } = processText(prevIndex, newText, curText.text);
// Compute the difference between current text and new text.
let done = false;
let idx = 0;
@@ -619,5 +635,6 @@ namespace Private {
idx++;
}
}
+ return index;
}
}
From 5ae2757382fddc21136f0bcb029456784399a97b Mon Sep 17 00:00:00 2001
From: krassowski <5832902+krassowski@users.noreply.github.com>
Date: Tue, 26 Nov 2024 20:58:03 +0000
Subject: [PATCH 09/14] Fix interaction with search highlights
---
packages/rendermime/src/renderers.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/packages/rendermime/src/renderers.ts b/packages/rendermime/src/renderers.ts
index 0e9652a66ad4..65407983d496 100644
--- a/packages/rendermime/src/renderers.ts
+++ b/packages/rendermime/src/renderers.ts
@@ -865,7 +865,11 @@ function renderTextual(
}
cacheStore.set(host, {
preTextContent,
- linkedNodes
+ // 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() as HTMLAnchorElement | Text
+ )
});
} else {
linkedNodes = [document.createTextNode(content)];
From db3a3c0348648780f93647ac007983621285bced Mon Sep 17 00:00:00 2001
From: krassowski <5832902+krassowski@users.noreply.github.com>
Date: Tue, 26 Nov 2024 22:11:09 +0000
Subject: [PATCH 10/14] Use deep cloning so that link label is preserved
---
packages/rendermime/src/renderers.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/rendermime/src/renderers.ts b/packages/rendermime/src/renderers.ts
index 65407983d496..3ea482738d2b 100644
--- a/packages/rendermime/src/renderers.ts
+++ b/packages/rendermime/src/renderers.ts
@@ -868,7 +868,7 @@ function renderTextual(
// 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() as HTMLAnchorElement | Text
+ node => node.cloneNode(true) as HTMLAnchorElement | Text
)
});
} else {
From 48908a8b07128b651faab72434f8d442b99bc91c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Krassowski?=
<5832902+krassowski@users.noreply.github.com>
Date: Wed, 27 Nov 2024 20:22:09 +0000
Subject: [PATCH 11/14] Pin Python version for visual regression testing to
3.11 (#16989)
---
.github/workflows/galata.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/galata.yml b/.github/workflows/galata.yml
index fcf28f674dc3..662cab947922 100644
--- a/.github/workflows/galata.yml
+++ b/.github/workflows/galata.yml
@@ -23,6 +23,8 @@ jobs:
- name: Base Setup
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
+ with:
+ python_version: "3.11"
- name: Set up browser cache
uses: actions/cache@v4
From c95b919314fce029068b217a71989983d04af1cb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Krassowski?=
<5832902+krassowski@users.noreply.github.com>
Date: Thu, 28 Nov 2024 09:54:43 +0000
Subject: [PATCH 12/14] Apply suggestions from code review
Co-authored-by: M Bussonnier
---
packages/rendermime/src/renderers.ts | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/packages/rendermime/src/renderers.ts b/packages/rendermime/src/renderers.ts
index 3ea482738d2b..65c2a31331b5 100644
--- a/packages/rendermime/src/renderers.ts
+++ b/packages/rendermime/src/renderers.ts
@@ -804,7 +804,7 @@ export function renderText(options: renderText.IRenderOptions): Promise {
function renderTextual(
options: renderText.IRenderOptions,
autoLinkOptions: IAutoLinkOptions
-) {
+): void {
// Unpack the options.
const { host, sanitizer, source } = options;
@@ -819,7 +819,7 @@ function renderTextual(
const preTextContent = pre.textContent;
- let cacheStoreOptions = [];
+ const cacheStoreOptions = [];
if (autoLinkOptions.checkWeb) {
cacheStoreOptions.push('web');
}
@@ -843,8 +843,7 @@ function renderTextual(
preTextContent
);
if (cache) {
- const { cachedNodes, addedText } = cache;
- const fromCache = cachedNodes;
+ const { cachedNodes: fromCache, addedText } = cache;
const newAdditions = autolink(addedText, autoLinkOptions);
const lastInCache = fromCache[fromCache.length - 1];
const firstNewNode = newAdditions[0];
@@ -951,16 +950,17 @@ function getApplicableLinkCache(
cachedResult.preTextContent.endsWith('\n') ||
addedText.startsWith('\n')
) {
- // continue
+ // Second or third condition is met, we can use the cached nodes
+ // (this is a no-op, we just continue execution).
} else if (lastCachedNode instanceof Text) {
- // Remove the Text node to re-analyse this text.
+ // The first condition is met, we can use the cached nodes,
+ // but first we remove the Text node to re-analyse its text.
// This is required when we cached `aaa www.one.com bbb www.`
// and the incoming addition is `two.com`. We can still
// use text node `aaa ` and anchor node `www.one.com`, but
// we need to pass `bbb www.` + `two.com` through linkify again.
cachedNodes = cachedNodes.slice(0, -1);
addedText = lastCachedNode.textContent + addedText;
- // continue
} else {
return null;
}
From 1aaebbd1d5de5d176dd7d3b58e6958cd76450cab Mon Sep 17 00:00:00 2001
From: krassowski <5832902+krassowski@users.noreply.github.com>
Date: Fri, 29 Nov 2024 10:16:55 +0000
Subject: [PATCH 13/14] Fix tests
---
packages/rendermime/test/factories.spec.ts | 44 ++++++++++++++++++----
1 file changed, 37 insertions(+), 7 deletions(-)
diff --git a/packages/rendermime/test/factories.spec.ts b/packages/rendermime/test/factories.spec.ts
index 51f3be7714e0..d9d3db2463e2 100644
--- a/packages/rendermime/test/factories.spec.ts
+++ b/packages/rendermime/test/factories.spec.ts
@@ -538,6 +538,25 @@ describe('rendermime/factories', () => {
};
describe('#createRenderer()', () => {
+ // Mock creation of DOM nodes to distinguish cached
+ /// (cloned) nodes from nodes created from scratch.
+ beforeEach(() => {
+ const originalCloneNode = Node.prototype.cloneNode;
+
+ Node.prototype.cloneNode = function (...args: any) {
+ const clonedNode = originalCloneNode.apply(this, args);
+
+ // Annotate as a node created by cloning.
+ clonedNode.wasCloned = true;
+
+ return clonedNode;
+ };
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
it('should output the correct HTML', async () => {
const f = errorRendererFactory;
const mimeType = 'application/vnd.jupyter.stderr';
@@ -649,10 +668,13 @@ describe('rendermime/factories', () => {
expect(before).not.toEqual(after);
expect(after).toContain('line of text');
- // If cached links were used, the anchor nodes will be reused.
expect(cachedLink).not.toBe(null);
- expect(cachedLink).toBe(w.node.childNodes[0].childNodes[0]);
- expect(cachedLink).toBe(linkAfter);
+ expect(cachedLink).not.toHaveProperty('wasCloned');
+
+ // If cached links were reused those would be cloned
+ expect(linkAfter).not.toBe(null);
+ expect(linkAfter).toEqual(cachedLink);
+ expect(linkAfter).toHaveProperty('wasCloned', true);
}
);
@@ -685,9 +707,14 @@ describe('rendermime/factories', () => {
// The contents of the node should be updated with the new line.
expect(before).not.toEqual(after);
- // If cached links were used, the anchor nodes will be reused.
expect(cachedLink).not.toBe(null);
- expect(cachedLink).not.toBe(linkAfter);
+ expect(cachedLink!.textContent).toEqual('www.example.co');
+ expect(cachedLink).not.toHaveProperty('wasCloned');
+
+ // If cached links were reused those would be cloned
+ expect(linkAfter).not.toBe(null);
+ expect(linkAfter!.textContent).toEqual('www.example.com');
+ expect(linkAfter).not.toHaveProperty('wasCloned');
});
it('should use partial cache if a link is created by addition of a new fragment', async () => {
@@ -721,9 +748,12 @@ describe('rendermime/factories', () => {
// it should fragment properly linkify the second link
expect(linksAfter).toHaveLength(2);
- // If cached nodes were used, the text nodes will be reused.
expect(cachedTextNode).toBeInstanceOf(Text);
- expect(cachedTextNode).toBe(textNodeAfter);
+ expect(cachedTextNode).not.toHaveProperty('wasCloned');
+
+ // If cached nodes were reused those would be cloned
+ expect(textNodeAfter).toEqual(cachedTextNode);
+ expect(textNodeAfter).toHaveProperty('wasCloned', true);
});
it('should autolink a single known file path', async () => {
From f28b4145d421560b95ee95988c1310d13eafab14 Mon Sep 17 00:00:00 2001
From: David Brochart
Date: Fri, 29 Nov 2024 11:17:33 +0100
Subject: [PATCH 14/14] Bump httpx v0.28.0 (#17013)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Bump httpx v0.28.0
* Use `~=` for httpx
---------
Co-authored-by: MichaĆ Krassowski <5832902+krassowski@users.noreply.github.com>
---
jupyterlab/extensions/pypi.py | 6 +++---
pyproject.toml | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/jupyterlab/extensions/pypi.py b/jupyterlab/extensions/pypi.py
index 413023222639..e97f0a6f55ae 100644
--- a/jupyterlab/extensions/pypi.py
+++ b/jupyterlab/extensions/pypi.py
@@ -65,8 +65,8 @@ def make_connection(self, host):
proxy_host, _, proxy_port = http_proxy.netloc.partition(":")
proxies = {
- "http://": http_proxy_url,
- "https://": https_proxy_url,
+ "http://": httpx.HTTPTransport(proxy=http_proxy_url),
+ "https://": httpx.HTTPTransport(proxy=https_proxy_url),
}
xmlrpc_transport_override = ProxiedTransport()
@@ -131,7 +131,7 @@ def __init__(
parent: Optional[config.Configurable] = None,
) -> None:
super().__init__(app_options, ext_options, parent)
- self._httpx_client = httpx.AsyncClient(proxies=proxies)
+ self._httpx_client = httpx.AsyncClient(mounts=proxies)
# Set configurable cache size to fetch function
self._fetch_package_metadata = partial(_fetch_package_metadata, self._httpx_client)
self._observe_package_metadata_cache_size({"new": self.package_metadata_cache_size})
diff --git a/pyproject.toml b/pyproject.toml
index 1d19bc2df2ab..33c89595858f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,7 +36,7 @@ classifiers = [
]
dependencies = [
"async_lru>=1.0.0",
- "httpx>=0.25.0",
+ "httpx~=0.28.0",
"importlib-metadata>=4.8.3;python_version<\"3.10\"",
"importlib-resources>=1.4;python_version<\"3.9\"",
"ipykernel>=6.5.0",