Skip to content

Commit 3748dac

Browse files
authored
feat: Show loading spinners immediately in ui (#1023)
I can't find any tickets on this even though I thought we had some. Fixes a few issues 1. Immediately open a panel and show a loading spinner before server rendering is complete The panel will be re-used and have its title updated if necessary when rendering completes. Works with multiple panels (opens 1 panel for loading and then re-uses for the 1st of multiple panels) 2. Shows a loader for all panels on page reload until rendering is complete. Previously, we rendered a blank panel until re-hydration was complete 3. If a widget throws a rendering error, the state is reset to loading when the "Reload" button is pressed. A loader is immediately shown until the reload is over instead of just showing the error message.
1 parent 701b004 commit 3748dac

File tree

8 files changed

+179
-67
lines changed

8 files changed

+179
-67
lines changed

plugins/ui/src/js/src/DashboardPlugin.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@ import {
2727
import PortalPanel from './layout/PortalPanel';
2828
import PortalPanelManager from './layout/PortalPanelManager';
2929
import DashboardWidgetHandler from './widget/DashboardWidgetHandler';
30-
import { getPreservedData } from './widget/WidgetUtils';
30+
import {
31+
getPreservedData,
32+
DASHBOARD_ELEMENT,
33+
WIDGET_ELEMENT,
34+
} from './widget/WidgetUtils';
3135

32-
const NAME_ELEMENT = 'deephaven.ui.Element';
33-
const DASHBOARD_ELEMENT = 'deephaven.ui.Dashboard';
3436
const PLUGIN_NAME = '@deephaven/js-plugin-ui.DashboardPlugin';
3537

3638
const log = Log.module('@deephaven/js-plugin-ui.DashboardPlugin');
@@ -119,7 +121,7 @@ export function DashboardPlugin(
119121
const { type } = widget;
120122

121123
switch (type) {
122-
case NAME_ELEMENT: {
124+
case WIDGET_ELEMENT: {
123125
handleWidgetOpen({ widgetId, widget });
124126
break;
125127
}

plugins/ui/src/js/src/widget/WidgetHandler.tsx

Lines changed: 55 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,16 @@ import {
3737
METHOD_DOCUMENT_UPDATED,
3838
} from './WidgetTypes';
3939
import DocumentHandler from './DocumentHandler';
40-
import { getComponentForElement, wrapCallable } from './WidgetUtils';
40+
import {
41+
getComponentForElement,
42+
WIDGET_ELEMENT,
43+
wrapCallable,
44+
} from './WidgetUtils';
4145
import WidgetStatusContext, {
4246
WidgetStatus,
4347
} from '../layout/WidgetStatusContext';
4448
import WidgetErrorView from './WidgetErrorView';
49+
import ReactPanel from '../layout/ReactPanel';
4550

4651
const log = Log.module('@deephaven/js-plugin-ui/WidgetHandler');
4752

@@ -77,13 +82,33 @@ function WidgetHandler({
7782
setIsLoading(true);
7883
}
7984

80-
const [document, setDocument] = useState<ReactNode>();
85+
if (widgetError != null && isLoading) {
86+
setIsLoading(false);
87+
}
8188

8289
// We want to update the initial data if the widget changes, as we'll need to re-fetch the widget and want to start with a fresh state.
8390
// eslint-disable-next-line react-hooks/exhaustive-deps
8491
const initialData = useMemo(() => initialDataProp, [widget]);
8592
const [internalError, setInternalError] = useState<WidgetError>();
8693

94+
const [document, setDocument] = useState<ReactNode>(() => {
95+
if (widgetDescriptor.type === WIDGET_ELEMENT) {
96+
// Rehydration. Mount ReactPanels for each panelId in the initial data
97+
// so loading spinners or widget errors are shown
98+
if (initialData?.panelIds != null && initialData.panelIds.length > 0) {
99+
// Do not add a key here
100+
// When the real document mounts, it doesn't use keys and will cause a remount
101+
// which triggers the DocumentHandler to think the panels were closed and messes up the layout
102+
// eslint-disable-next-line react/jsx-key
103+
return initialData.panelIds.map(() => <ReactPanel />);
104+
}
105+
// Default to a single panel so we can immediately show a loading spinner
106+
return <ReactPanel />;
107+
}
108+
// Dashboards should not have a default document. It breaks its render flow
109+
return null;
110+
});
111+
87112
const error = useMemo(
88113
() => internalError ?? widgetError ?? undefined,
89114
[internalError, widgetError]
@@ -261,9 +286,16 @@ function WidgetHandler({
261286
const newError: WidgetError = JSON.parse(params[0]);
262287
newError.action = {
263288
title: 'Reload',
264-
action: () => sendSetState(),
289+
action: () => {
290+
setInternalError(undefined);
291+
setIsLoading(true);
292+
sendSetState();
293+
},
265294
};
266-
setInternalError(newError);
295+
unstable_batchedUpdates(() => {
296+
setIsLoading(false);
297+
setInternalError(newError);
298+
});
267299
});
268300

269301
return () => {
@@ -345,48 +377,34 @@ function WidgetHandler({
345377
return document;
346378
}
347379
if (error != null) {
348-
// If there's an error and the document hasn't rendered yet, explicitly show an error view
380+
// If there's an error and the document hasn't rendered yet (mostly applies to dashboards), explicitly show an error view
349381
return <WidgetErrorView error={error} />;
350382
}
351-
return null;
383+
return document;
352384
}, [document, error]);
353385

354386
const widgetStatus: WidgetStatus = useMemo(() => {
355-
if (error != null) {
356-
return { status: 'error', descriptor: widgetDescriptor, error };
357-
}
358387
if (isLoading) {
359388
return { status: 'loading', descriptor: widgetDescriptor };
360389
}
361-
if (renderedDocument != null) {
362-
return { status: 'ready', descriptor: widgetDescriptor };
390+
if (error != null) {
391+
return { status: 'error', descriptor: widgetDescriptor, error };
363392
}
364-
return { status: 'loading', descriptor: widgetDescriptor };
365-
}, [error, renderedDocument, widgetDescriptor, isLoading]);
366-
367-
return useMemo(
368-
() =>
369-
renderedDocument ? (
370-
<WidgetStatusContext.Provider value={widgetStatus}>
371-
<DocumentHandler
372-
widget={widgetDescriptor}
373-
initialData={initialData}
374-
onDataChange={onDataChange}
375-
onClose={onClose}
376-
>
377-
{renderedDocument}
378-
</DocumentHandler>
379-
</WidgetStatusContext.Provider>
380-
) : null,
381-
[
382-
widgetDescriptor,
383-
renderedDocument,
384-
initialData,
385-
onClose,
386-
onDataChange,
387-
widgetStatus,
388-
]
389-
);
393+
return { status: 'ready', descriptor: widgetDescriptor };
394+
}, [error, widgetDescriptor, isLoading]);
395+
396+
return renderedDocument != null ? (
397+
<WidgetStatusContext.Provider value={widgetStatus}>
398+
<DocumentHandler
399+
widget={widgetDescriptor}
400+
initialData={initialData}
401+
onDataChange={onDataChange}
402+
onClose={onClose}
403+
>
404+
{renderedDocument}
405+
</DocumentHandler>
406+
</WidgetStatusContext.Provider>
407+
) : null;
390408
}
391409

392410
WidgetHandler.displayName = '@deephaven/js-plugin-ui/WidgetHandler';

plugins/ui/src/js/src/widget/WidgetUtils.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ import {
8282
Tabs,
8383
} from '../elements';
8484

85+
export const WIDGET_ELEMENT = 'deephaven.ui.Element';
86+
export const DASHBOARD_ELEMENT = 'deephaven.ui.Dashboard';
87+
8588
/**
8689
* Elements to implicitly wrap primitive children in <Text> components.
8790
*/

tests/app.d/tests.app

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ file_1=matplotlib.py
88
file_2=ui.py
99
file_3=ui_render_all.py
1010
file_4=ui_flex.py
11-
file_5=ui_table.py
11+
file_5=ui_table.py
12+
file_6=ui_panel_loading.py

tests/app.d/ui_panel_loading.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from deephaven import ui
2+
import time
3+
4+
5+
@ui.component
6+
def ui_boom_button():
7+
value, set_value = ui.use_state(0)
8+
9+
if value > 0:
10+
raise ValueError("BOOM! Value too big.")
11+
12+
return ui.button("Go BOOM!", on_press=lambda _: set_value(value + 1))
13+
14+
15+
@ui.component
16+
def ui_slow_multi_panel_component():
17+
is_mounted, set_is_mounted = ui.use_state(None)
18+
if not is_mounted:
19+
time.sleep(1)
20+
set_is_mounted(ui_boom_button) # type: ignore Use a complex value that won't save between page loads
21+
return [
22+
ui.panel(ui.button("Hello")),
23+
ui.panel(ui.text("World")),
24+
ui.panel(ui_boom_button()),
25+
]
26+
27+
28+
ui_slow_multi_panel = ui_slow_multi_panel_component()

tests/ui.spec.ts

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,33 @@
11
import { expect, test } from '@playwright/test';
2-
import { openPanel, gotoPage } from './utils';
3-
4-
const selector = {
5-
REACT_PANEL_VISIBLE: '.dh-react-panel:visible',
6-
REACT_PANEL_OVERLAY: '.dh-react-panel-overlay',
7-
};
2+
import { openPanel, gotoPage, SELECTORS } from './utils';
83

94
test('UI loads', async ({ page }) => {
105
await gotoPage(page, '');
11-
await openPanel(page, 'ui_component', selector.REACT_PANEL_VISIBLE);
12-
await expect(page.locator(selector.REACT_PANEL_VISIBLE)).toHaveScreenshot();
6+
await openPanel(page, 'ui_component', SELECTORS.REACT_PANEL_VISIBLE);
7+
await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toHaveScreenshot();
138
});
149

1510
test('boom component shows an error in a panel', async ({ page }) => {
1611
await gotoPage(page, '');
17-
await openPanel(page, 'ui_boom', selector.REACT_PANEL_VISIBLE);
18-
await expect(page.locator(selector.REACT_PANEL_VISIBLE)).toBeVisible();
12+
await openPanel(page, 'ui_boom', SELECTORS.REACT_PANEL_VISIBLE);
13+
await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toBeVisible();
1914
await expect(
2015
page
21-
.locator(selector.REACT_PANEL_VISIBLE)
16+
.locator(SELECTORS.REACT_PANEL_VISIBLE)
2217
.getByText('Exception', { exact: true })
2318
).toBeVisible();
2419
await expect(
25-
page.locator(selector.REACT_PANEL_VISIBLE).getByText('BOOM!')
20+
page.locator(SELECTORS.REACT_PANEL_VISIBLE).getByText('BOOM!')
2621
).toBeVisible();
2722
});
2823

2924
test('boom counter component shows error overlay after clicking the button twice', async ({
3025
page,
3126
}) => {
3227
await gotoPage(page, '');
33-
await openPanel(page, 'ui_boom_counter', selector.REACT_PANEL_VISIBLE);
28+
await openPanel(page, 'ui_boom_counter', SELECTORS.REACT_PANEL_VISIBLE);
3429

35-
const panelLocator = page.locator(selector.REACT_PANEL_VISIBLE);
30+
const panelLocator = page.locator(SELECTORS.REACT_PANEL_VISIBLE);
3631

3732
let btn = await panelLocator.getByRole('button', { name: 'Count is 0' });
3833
await expect(btn).toBeVisible();
@@ -50,7 +45,7 @@ test('boom counter component shows error overlay after clicking the button twice
5045

5146
test('Using keys for lists works', async ({ page }) => {
5247
await gotoPage(page, '');
53-
await openPanel(page, 'ui_cells', selector.REACT_PANEL_VISIBLE);
48+
await openPanel(page, 'ui_cells', SELECTORS.REACT_PANEL_VISIBLE);
5449

5550
// setup cells
5651
await page.getByRole('button', { name: 'Add cell' }).click();
@@ -69,14 +64,14 @@ test('Using keys for lists works', async ({ page }) => {
6964

7065
test('UI all components render 1', async ({ page }) => {
7166
await gotoPage(page, '');
72-
await openPanel(page, 'ui_render_all1', selector.REACT_PANEL_VISIBLE);
73-
await expect(page.locator(selector.REACT_PANEL_VISIBLE)).toHaveScreenshot();
67+
await openPanel(page, 'ui_render_all1', SELECTORS.REACT_PANEL_VISIBLE);
68+
await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toHaveScreenshot();
7469
});
7570

7671
test('UI all components render 2', async ({ page }) => {
7772
await gotoPage(page, '');
78-
await openPanel(page, 'ui_render_all2', selector.REACT_PANEL_VISIBLE);
79-
await expect(page.locator(selector.REACT_PANEL_VISIBLE)).toHaveScreenshot();
73+
await openPanel(page, 'ui_render_all2', SELECTORS.REACT_PANEL_VISIBLE);
74+
await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toHaveScreenshot();
8075
});
8176

8277
// Tests flex components render as expected
@@ -110,18 +105,18 @@ test.describe('UI flex components', () => {
110105
].forEach(i => {
111106
test(i.name, async ({ page }) => {
112107
await gotoPage(page, '');
113-
await openPanel(page, i.name, selector.REACT_PANEL_VISIBLE);
108+
await openPanel(page, i.name, SELECTORS.REACT_PANEL_VISIBLE);
114109

115110
// need to wait for plots to be loaded before taking screenshot
116111
// easiest way to check that is if the traces are present
117112
if (i.traces > 0) {
118113
await expect(
119-
await page.locator(selector.REACT_PANEL_VISIBLE).locator('.trace')
114+
await page.locator(SELECTORS.REACT_PANEL_VISIBLE).locator('.trace')
120115
).toHaveCount(i.traces);
121116
}
122117

123118
await expect(
124-
page.locator(selector.REACT_PANEL_VISIBLE)
119+
page.locator(SELECTORS.REACT_PANEL_VISIBLE)
125120
).toHaveScreenshot();
126121
});
127122
});

tests/ui_loading.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { expect, test } from '@playwright/test';
2+
import { openPanel, gotoPage, SELECTORS } from './utils';
3+
4+
test('slow multi-panel shows 1 loader immediately and multiple after loading', async ({
5+
page,
6+
}) => {
7+
await gotoPage(page, '');
8+
await openPanel(
9+
page,
10+
'ui_slow_multi_panel',
11+
SELECTORS.REACT_PANEL_VISIBLE,
12+
false
13+
);
14+
const locator = page.locator(SELECTORS.REACT_PANEL);
15+
// 1 loader should show up
16+
await expect(locator.locator('.loading-spinner')).toHaveCount(1);
17+
// Then disappear and show 3 panels
18+
await expect(locator.locator('.loading-spinner')).toHaveCount(0);
19+
await expect(locator).toHaveCount(3);
20+
await expect(locator.getByText('Hello')).toHaveCount(1);
21+
await expect(locator.getByText('World')).toHaveCount(1);
22+
await expect(locator.getByText('Go BOOM!')).toHaveCount(1);
23+
});
24+
25+
test('slow multi-panel shows loaders on element Reload', async ({ page }) => {
26+
await gotoPage(page, '');
27+
await openPanel(page, 'ui_slow_multi_panel', SELECTORS.REACT_PANEL_VISIBLE);
28+
const locator = page.locator(SELECTORS.REACT_PANEL);
29+
await expect(locator).toHaveCount(3);
30+
await locator.getByText('Go BOOM!').click();
31+
await expect(locator.getByText('ValueError', { exact: true })).toHaveCount(3);
32+
await expect(locator.getByText('BOOM!')).toHaveCount(3);
33+
await locator.locator(':visible').getByText('Reload').first().click();
34+
// Loaders should show up
35+
await expect(locator.locator('.loading-spinner')).toHaveCount(3);
36+
// Then disappear and show components again
37+
await expect(locator.locator('.loading-spinner')).toHaveCount(0);
38+
await expect(locator.getByText('Hello')).toHaveCount(1);
39+
await expect(locator.getByText('World')).toHaveCount(1);
40+
await expect(locator.getByText('Go BOOM!')).toHaveCount(1);
41+
});
42+
43+
test('slow multi-panel shows loaders on page reload', async ({ page }) => {
44+
await gotoPage(page, '');
45+
await openPanel(page, 'ui_slow_multi_panel', SELECTORS.REACT_PANEL_VISIBLE);
46+
await page.reload();
47+
const locator = page.locator(SELECTORS.REACT_PANEL);
48+
// Loader should show up
49+
await expect(locator.locator('.loading-spinner')).toHaveCount(3);
50+
// Then disappear and show error again
51+
await expect(locator.locator('.loading-spinner')).toHaveCount(0);
52+
await expect(locator.getByText('Hello')).toHaveCount(1);
53+
await expect(locator.getByText('World')).toHaveCount(1);
54+
await expect(locator.getByText('Go BOOM!')).toHaveCount(1);
55+
});

0 commit comments

Comments
 (0)