Skip to content

Commit ca5f6cd

Browse files
authored
fix: Console history did not stick to bottom on visibility changes (#2324)
- When debugging, I found that the `Code` block was rendering while the Console tab was in the background (invisible, size 0) so when it did finally render, there was a lot of content added but it wasn't scrolled. By checking sticky bottom when visibility changes, we handle this situtation where content causes a resize while it is invisible - Use an IntersectionObserver to detect visibility changes: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API - Tested by running the following: 1. Run a code snippet to print a bunch of lines, causing the console history to scroll: ```python for i in range(0, 200): print("i is", i) ``` 2. Run a snippet to create a dashboard, and have a lot of text in the command: ```python from deephaven import ui ... have lots of blank lines so that it takes up more than a page of scroll space d = ui.dashboard(ui.panel("Hello")) ``` 3. Go back to the Console tab, see that it is still scroll to the bottom.
1 parent 9899c1c commit ca5f6cd

4 files changed

Lines changed: 136 additions & 9 deletions

File tree

jest.setup.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ Object.defineProperty(window, 'ResizeObserver', {
5050
},
5151
});
5252

53+
Object.defineProperty(window, 'IntersectionObserver', {
54+
value: function () {
55+
return TestUtils.createMockProxy<IntersectionObserver>();
56+
},
57+
});
58+
5359
Object.defineProperty(window, 'DOMRect', {
5460
value: function (x: number = 0, y: number = 0, width = 0, height = 0) {
5561
return TestUtils.createMockProxy<DOMRect>({

packages/console/src/Console.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import React, {
77
type ReactElement,
88
type ReactNode,
99
type RefObject,
10+
type UIEvent,
1011
} from 'react';
1112
import {
1213
ContextActions,
@@ -190,6 +191,7 @@ export class Console extends PureComponent<ConsoleProps, ConsoleState> {
190191
this.handleLogMessage = this.handleLogMessage.bind(this);
191192
this.handleOverflowActions = this.handleOverflowActions.bind(this);
192193
this.handleScrollPaneScroll = this.handleScrollPaneScroll.bind(this);
194+
this.handleHistoryResize = this.handleHistoryResize.bind(this);
193195
this.handleToggleAutoLaunchPanels =
194196
this.handleToggleAutoLaunchPanels.bind(this);
195197
this.handleToggleClosePanelsOnDisconnect =
@@ -211,8 +213,10 @@ export class Console extends PureComponent<ConsoleProps, ConsoleState> {
211213
this.consolePane = React.createRef();
212214
this.consoleInput = React.createRef();
213215
this.consoleHistoryScrollPane = React.createRef();
216+
this.consoleHistoryContent = React.createRef();
214217
this.pending = new Pending();
215218
this.queuedLogMessages = [];
219+
this.resizeObserver = new window.ResizeObserver(this.handleHistoryResize);
216220

217221
const { objectMap, settings } = this.props;
218222

@@ -253,6 +257,14 @@ export class Console extends PureComponent<ConsoleProps, ConsoleState> {
253257
);
254258

255259
this.updateDimensions();
260+
261+
if (
262+
this.consoleHistoryScrollPane.current &&
263+
this.consoleHistoryContent.current
264+
) {
265+
this.resizeObserver.observe(this.consoleHistoryScrollPane.current);
266+
this.resizeObserver.observe(this.consoleHistoryContent.current);
267+
}
256268
}
257269

258270
componentDidUpdate(prevProps: ConsoleProps, prevState: ConsoleState): void {
@@ -280,6 +292,7 @@ export class Console extends PureComponent<ConsoleProps, ConsoleState> {
280292
this.processLogMessageQueue.cancel();
281293

282294
this.deinitConsoleLogging();
295+
this.resizeObserver.disconnect();
283296
}
284297

285298
cancelListener?: () => void;
@@ -290,10 +303,14 @@ export class Console extends PureComponent<ConsoleProps, ConsoleState> {
290303

291304
consoleHistoryScrollPane: RefObject<HTMLDivElement>;
292305

306+
consoleHistoryContent: RefObject<HTMLDivElement>;
307+
293308
pending: Pending;
294309

295310
queuedLogMessages: ConsoleHistoryActionItem[];
296311

312+
resizeObserver: ResizeObserver;
313+
297314
initConsoleLogging(): void {
298315
const { session } = this.props;
299316
this.cancelListener = session.onLogMessage(this.handleLogMessage);
@@ -666,11 +683,12 @@ export class Console extends PureComponent<ConsoleProps, ConsoleState> {
666683
}
667684

668685
window.requestAnimationFrame(() => {
669-
pane.scrollTo({ top: pane.scrollHeight });
686+
pane.scrollTo({ top: pane.scrollHeight, behavior: 'instant' });
670687
});
671688
}
672689

673-
handleScrollPaneScroll(): void {
690+
handleScrollPaneScroll(event: UIEvent<HTMLDivElement>): void {
691+
log.debug('handleScrollPaneScroll', event);
674692
const scrollPane = this.consoleHistoryScrollPane.current;
675693
assertNotNull(scrollPane);
676694
this.setState({
@@ -681,6 +699,17 @@ export class Console extends PureComponent<ConsoleProps, ConsoleState> {
681699
});
682700
}
683701

702+
handleHistoryResize(entries: ResizeObserverEntry[]): void {
703+
log.debug('handleHistoryResize', entries);
704+
const entry = entries[0];
705+
if (entry.contentRect.height > 0 && entry.contentRect.width > 0) {
706+
const { isStuckToBottom } = this.state;
707+
if (isStuckToBottom && !this.isAtBottom()) {
708+
this.scrollConsoleHistoryToBottom();
709+
}
710+
}
711+
}
712+
684713
handleToggleAutoLaunchPanels(): void {
685714
this.setState(state => ({
686715
isAutoLaunchPanelsEnabled: !state.isAutoLaunchPanelsEnabled,
@@ -1067,6 +1096,7 @@ export class Console extends PureComponent<ConsoleProps, ConsoleState> {
10671096
disabled={disabled}
10681097
supportsType={supportsType}
10691098
iconForType={iconForType}
1099+
ref={this.consoleHistoryContent}
10701100
/>
10711101
{historyChildren}
10721102
</div>

packages/console/src/console-history/ConsoleHistory.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Console display for use in the Iris environment.
33
*/
4-
import { type ReactElement } from 'react';
4+
import React, { type ReactElement } from 'react';
55
import type { dh } from '@deephaven/jsapi-types';
66
import ConsoleHistoryItem from './ConsoleHistoryItem';
77

@@ -23,7 +23,13 @@ function itemKey(i: number, item: ConsoleHistoryActionItem): string {
2323
}`;
2424
}
2525

26-
function ConsoleHistory(props: ConsoleHistoryProps): ReactElement {
26+
/**
27+
* Display the console history.
28+
*/
29+
const ConsoleHistory = React.forwardRef(function ConsoleHistory(
30+
props: ConsoleHistoryProps,
31+
ref: React.Ref<HTMLDivElement>
32+
): ReactElement {
2733
const {
2834
disabled = false,
2935
items,
@@ -50,8 +56,10 @@ function ConsoleHistory(props: ConsoleHistoryProps): ReactElement {
5056
}
5157

5258
return (
53-
<div className="container-fluid console-history">{historyElements}</div>
59+
<div ref={ref} className="container-fluid console-history">
60+
{historyElements}
61+
</div>
5462
);
55-
}
63+
});
5664

5765
export default ConsoleHistory;

tests/console.spec.ts

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@ import {
66
generateId,
77
} from './utils';
88

9+
function logMessageLocator(page: Page, text?: string): Locator {
10+
return page
11+
.locator('.console-history .log-message')
12+
.filter({ hasText: text });
13+
}
14+
15+
function historyContentLocator(page: Page, text?: string): Locator {
16+
return page
17+
.locator('.console-history .console-history-content')
18+
.filter({ hasText: text });
19+
}
20+
21+
function panelTabLocator(page: Page, text: string): Locator {
22+
return page.locator('.lm_tab .lm_title').filter({ hasText: text });
23+
}
24+
25+
function scrollPanelLocator(page: Page): Locator {
26+
return page.locator('.console-pane .scroll-pane');
27+
}
28+
929
let page: Page;
1030
let consoleInput: Locator;
1131

@@ -32,9 +52,8 @@ test.describe('console input tests', () => {
3252
await page.keyboard.press('Enter');
3353

3454
// Expect the output to show up in the log
35-
await expect(
36-
page.locator('.console-history .log-message').filter({ hasText: message })
37-
).toHaveCount(1);
55+
await expect(logMessageLocator(page, message)).toHaveCount(1);
56+
await expect(logMessageLocator(page, message)).toBeVisible();
3857
});
3958

4059
test('object button is created when creating a table', async ({
@@ -61,3 +80,67 @@ test.describe('console input tests', () => {
6180
await expect(btnLocator.nth(1)).not.toBeDisabled();
6281
});
6382
});
83+
84+
test.describe('console scroll tests', () => {
85+
test.beforeEach(async () => {
86+
// Whenever we start a session, the server sends it logs over
87+
// We should wait for those to appear before running commands
88+
await logMessageLocator(page).first().waitFor();
89+
});
90+
91+
test('scrolls to the bottom when command is executed', async () => {
92+
// The command needs to be long, but it doesn't need to actually print anything
93+
const ids = Array.from(Array(50).keys()).map(() => generateId());
94+
const command = ids.map(i => `# Really long command ${i}`).join('\n');
95+
96+
await pasteInMonaco(consoleInput, command);
97+
await page.keyboard.press('Enter');
98+
99+
await historyContentLocator(page, ids[ids.length - 1]).waitFor({
100+
state: 'attached',
101+
});
102+
103+
// Wait for the scroll to complete, since it starts on the next available animation frame
104+
await page.waitForTimeout(500);
105+
106+
// Expect the console to be scrolled to the bottom
107+
const scrollPane = await scrollPanelLocator(page);
108+
expect(
109+
await scrollPane.evaluate(el =>
110+
Math.floor(el.scrollHeight - el.scrollTop - el.clientHeight)
111+
)
112+
).toBeLessThanOrEqual(0);
113+
});
114+
115+
test('scrolls to the bottom when focus changed when command executed', async () => {
116+
// The command needs to be long, but it doesn't need to actually print anything
117+
const ids = Array.from(Array(50).keys()).map(() => generateId());
118+
const command = `import time\ntime.sleep(0.5)\n${ids
119+
.map(i => `# Really long command ${i}`)
120+
.join('\n')}`;
121+
122+
await pasteInMonaco(consoleInput, command);
123+
page.keyboard.press('Enter');
124+
125+
// Immediately open the log before the command code block has a chance to finish colorizing/rendering
126+
await panelTabLocator(page, 'Log').click();
127+
128+
// wait for a bit for the code block to render
129+
await historyContentLocator(page, ids[ids.length - 1]).waitFor({
130+
state: 'attached',
131+
});
132+
133+
// Switch back to the console, and expect it to be scrolled to the bottom
134+
await panelTabLocator(page, 'Console').click();
135+
136+
// Wait for the scroll to complete, since it starts on the next available animation frame
137+
await page.waitForTimeout(500);
138+
139+
const scrollPane = await scrollPanelLocator(page);
140+
expect(
141+
await scrollPane.evaluate(el =>
142+
Math.floor(el.scrollHeight - el.scrollTop - el.clientHeight)
143+
)
144+
).toBeLessThanOrEqual(0);
145+
});
146+
});

0 commit comments

Comments
 (0)