@iobroker/gui-components 10.3.2 → 10.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,7 @@ npm create vite@latest src -- --template react-ts
36
36
  "dependencies": {
37
37
  "@emotion/react": "^11.14.0",
38
38
  "@emotion/styled": "^11.14.1",
39
- "@iobroker/gui-components": "^10.3.2",
39
+ "@iobroker/gui-components": "^10.3.4",
40
40
  "@mui/icons-material": "^9.0.1",
41
41
  "@mui/material": "^9.0.1",
42
42
  "react": "^19.2.5",
@@ -877,6 +877,15 @@ You can find the migration instructions:
877
877
  -->
878
878
 
879
879
  ## Changelog
880
+ ### 10.3.4 (2026-09-23)
881
+
882
+ - (@GermanBluefox) Fixed: the schedule wizard threw the time of day away. A schedule with an exact time was saved as `{"time":{"exactTime":true}}` without the time itself, so it ran at 00:00 and the dialog showed `--:--` and "every day at undefined" when it was opened again (ioBroker.javascript#2386). A saved schedule that lacks a setting now also falls back to the default of that setting instead of `undefined`
883
+
884
+ ### 10.3.3 (2026-09-23)
885
+
886
+ - (@krobipd) Improved: the object browser renders the rows around the visible area instead of every open row of the tree. Selecting a row, applying a filter or folding a node rebuilt every open row, and that cost grows with the size of the tree: measured with 4003 rows open, one click on a row blocked the main thread for 3.9 seconds and a filter for 2.6 seconds - with 603 rows it was 0.4 seconds each. Now it is 0 ms in both cases, and the table holds 38 rows instead of 4003 (1985 instead of 164652 DOM nodes). Scrolling, scrolling to a row and the keyboard navigation reach every row as before
887
+ - (@krobipd) Improved: a value that changes no longer rebuilds every open row of the object browser. Until now one state answered with a render of the whole table, and with a few hundred rows open that blocked the main thread for about a fifth of a second per value - on an installation whose adapters report all the time the table never stood still. A row is kept as it is unless its own state changed or something happened that concerns every row (filter, columns, theme, selection, a rebuilt tree). Measured in the admin with 542 open rows and ten state changes: 1937 ms of blocked main thread before, none after, with the new values shown in every case
888
+
880
889
  ### 10.3.2 (2026-09-22)
881
890
 
882
891
  - (@GermanBluefox) Updated packages
@@ -39,6 +39,65 @@ export declare class ObjectBrowserClass extends Component<ObjectBrowserProps, Ob
39
39
  private treeRebuiltWhilePaused;
40
40
  /** Time of the first object change since the last tree rebuild (upper bound for the rebuild delay) */
41
41
  private objectsUpdateFirstTs;
42
+ /**
43
+ * Counts up on every render that can change ANY row - a new filter, other columns, another
44
+ * theme, a different selection, a rebuilt tree. A state change does NOT count it up: it only
45
+ * concerns the rows whose value changed. Together with `rowStateVersion` this is the memo key
46
+ * of a row (see `renderRow`), so a value that ticks every second no longer rebuilds the whole
47
+ * table.
48
+ *
49
+ * Whether a render concerns every row is decided in `render` and not by a flag that the caller
50
+ * sets: React may batch the render of a state change with a `setState` (a click) or with new
51
+ * props, and a flag would then keep every row as it was, although the selection has changed.
52
+ */
53
+ renderEpoch: number;
54
+ /**
55
+ * Set by every `forceUpdate()`: something changed that is not visible in `state` or `props` (a
56
+ * rebuilt tree, other column widths), so every row has to be built again. Only
57
+ * `updateRowsOnly` renders without it
58
+ */
59
+ private rowsInvalid;
60
+ /** `state` and `props` of the last render: a render with the same two cannot concern every row */
61
+ private lastRenderedState;
62
+ private lastRenderedProps;
63
+ /**
64
+ * row id -> the states the row showed when it was built last: its own value and the status
65
+ * states of a device (online, offline, error). The subscriptions that are still needed are
66
+ * collected from the rows of the window (see `checkUnsubscribes`) - a row that is served from
67
+ * the memo is not built again, but its entry here is still valid
68
+ */
69
+ readonly rowStates: Record<string, Set<string>>;
70
+ /**
71
+ * state id -> the rows that show this state although it is not their own: a device shows the
72
+ * status of its `statusStates`. A change of such a state has to render those rows as well
73
+ */
74
+ private readonly stateDependents;
75
+ /**
76
+ * Rows that are rendered above and below the visible area, so that scrolling does not run into
77
+ * a gap before the next render
78
+ */
79
+ private static readonly OVERSCAN_ROWS;
80
+ /** Every row of the table in the order in which it is shown (see `flattenItems`) */
81
+ private flatRows;
82
+ /**
83
+ * Where every row of `flatRows` begins, in pixels. One entry longer than `flatRows`: the last
84
+ * one is the height of the whole table
85
+ */
86
+ private rowOffsets;
87
+ /** A row measured another height than `rowOffsets` expected: calculate them again */
88
+ private rowOffsetsInvalid;
89
+ /** id -> the height the row really had. Only rows that are not `ROW_HEIGHT` high end up here */
90
+ private readonly measuredHeights;
91
+ /** Scroll position and height of the table container, the two values the window is cut from */
92
+ private scrollTop;
93
+ private viewportHeight;
94
+ private scrollFrame;
95
+ /** First and last row of the last rendered window, to notice that scrolling moved it */
96
+ private windowFirst;
97
+ private windowLast;
98
+ /** id -> how often the state of this row has changed. Part of the memo key of the row */
99
+ readonly rowStateVersion: Record<string, number>;
100
+ private stateVersionCounter;
42
101
  private selectFirst;
43
102
  /** Last navigation that was applied from `navigateTo` or reported via `onNavigateTo` (loop guard). */
44
103
  private lastNav;
@@ -105,7 +164,6 @@ export declare class ObjectBrowserClass extends Component<ObjectBrowserProps, Ob
105
164
  item: any;
106
165
  ts: number;
107
166
  };
108
- recordStates: Set<string>;
109
167
  styles: {
110
168
  cellIdIconFolder?: React.CSSProperties;
111
169
  cellIdIconDocument?: React.CSSProperties;
@@ -193,6 +251,13 @@ export declare class ObjectBrowserClass extends Component<ObjectBrowserProps, Ob
193
251
  */
194
252
  renderColumnsSelectorDialog(): JSX.Element | null;
195
253
  private getAdditionalColumns;
254
+ /**
255
+ * Remember that a row shows a state. Called while the row is built (see `renderLeaf`)
256
+ *
257
+ * @param rowId the row
258
+ * @param stateId the state it shows: its own or one of its status states
259
+ */
260
+ recordState(rowId: string, stateId: string): void;
196
261
  private checkUnsubscribes;
197
262
  /**
198
263
  * Find an item.
@@ -301,8 +366,43 @@ export declare class ObjectBrowserClass extends Component<ObjectBrowserProps, Ob
301
366
  get width(): Width;
302
367
  /** Watch the width of the table container to detect the width class (see `width`) */
303
368
  observeContainerWidth(): void;
369
+ /** Store the height of the container: it decides how many rows the window holds */
370
+ setViewportHeight(viewportHeight: number): void;
304
371
  /** Store the width class of the container if it changed */
305
372
  setContainerWidth(containerWidth: number): void;
373
+ /**
374
+ * The height a row needs.
375
+ *
376
+ * A row with an alias is higher than the others, and what a row really measured beats the
377
+ * estimate - the table has to know the height of rows it does not render at the moment,
378
+ * otherwise the scrollbar does not fit the content.
379
+ */
380
+ private rowHeight;
381
+ /** All rows in the order in which they are shown, and where each of them begins */
382
+ private buildFlatRows;
383
+ /** Where every row begins - again after a row measured another height than was expected */
384
+ private calculateRowOffsets;
385
+ /** The row that covers this position (binary search over `rowOffsets`) */
386
+ private findRowAt;
387
+ /**
388
+ * The rows that are rendered: the visible ones plus `OVERSCAN_ROWS` above and below.
389
+ *
390
+ * A tree with a few thousand open rows costs a fifth of a second of blocked main thread for
391
+ * every change that concerns all rows - selecting a row, a filter, folding a node - because
392
+ * every row is built anew. Only the rows around the visible area are rendered now, so that
393
+ * work no longer grows with the size of the tree.
394
+ */
395
+ private getWindow;
396
+ /** The table was scrolled: move the window of rendered rows, at most once per frame */
397
+ private readonly onTableScroll;
398
+ /**
399
+ * Remember how high the rendered rows really are.
400
+ *
401
+ * Rows are not all `ROW_HEIGHT` high (an alias is higher, the focused row of the narrow view
402
+ * has no fixed height at all), and a row outside the window cannot be measured - so every row
403
+ * that was rendered once keeps its measured height for the calculation of the window.
404
+ */
405
+ private measureRenderedRows;
306
406
  /**
307
407
  * All column widths as CSS variables of the table container. The header and every row read the
308
408
  * widths from here (see `colWidth`), so they can never get out of sync, and the resizer can
@@ -343,6 +443,13 @@ export declare class ObjectBrowserClass extends Component<ObjectBrowserProps, Ob
343
443
  * Called when component is updated.
344
444
  */
345
445
  componentDidUpdate(prevProps: ObjectBrowserProps): void;
446
+ /**
447
+ * Scrolls to a row.
448
+ *
449
+ * The row does not have to be rendered: only the rows around the visible area are (see
450
+ * `getWindow`), so the position is taken from the flat list of all rows and the row is brought
451
+ * into the middle of the table by scrolling there.
452
+ */
346
453
  scrollToItem(id: string): void;
347
454
  onUpdate(valAck: {
348
455
  val: ioBroker.StateValue;
@@ -353,6 +460,16 @@ export declare class ObjectBrowserClass extends Component<ObjectBrowserProps, Ob
353
460
  showAddDataPointDialog(id: string, initialType: ioBroker.ObjectType, initialStateType?: ioBroker.CommonType): void;
354
461
  /** Renders the aliases list for one state (if more than 2) */
355
462
  doFilter(doNotStore?: boolean): void;
463
+ /**
464
+ * Renders the browser and every row again: whoever calls it has changed something that is not
465
+ * visible in `state` or `props`, so the rows cannot keep their output
466
+ */
467
+ forceUpdate(callback?: () => void): void;
468
+ /**
469
+ * Renders the browser, but keeps the output of every row that did not change: a state changed
470
+ * (see `rowStateVersion`), the table was scrolled or a row measured another height
471
+ */
472
+ private updateRowsOnly;
356
473
  /**
357
474
  * The rendering method of this component.
358
475
  */
@@ -17,7 +17,7 @@ import { Utils } from '../Utils'; // @iobroker/gui-components/Components/Utils
17
17
  import { TabContainer } from '../TabContainer';
18
18
  import { TabContent } from '../TabContent';
19
19
  import { TabHeader } from '../TabHeader';
20
- import { applyFilter, buildTree, findNode, formatValue, generateFile, getName, colVar, widthFromContainer, growVar, getSelectIdIconFromObjects, setCustomValue, } from './utils';
20
+ import { ROW_HEIGHT, applyFilter, buildTree, findNode, formatValue, generateFile, getName, colVar, widthFromContainer, growVar, getSelectIdIconFromObjects, setCustomValue, } from './utils';
21
21
  import { HistoryChart } from './HistoryChart';
22
22
  import { styles } from './styles';
23
23
  import * as dialogs from './dialogs';
@@ -60,6 +60,65 @@ export class ObjectBrowserClass extends Component {
60
60
  treeRebuiltWhilePaused = false;
61
61
  /** Time of the first object change since the last tree rebuild (upper bound for the rebuild delay) */
62
62
  objectsUpdateFirstTs = 0;
63
+ /**
64
+ * Counts up on every render that can change ANY row - a new filter, other columns, another
65
+ * theme, a different selection, a rebuilt tree. A state change does NOT count it up: it only
66
+ * concerns the rows whose value changed. Together with `rowStateVersion` this is the memo key
67
+ * of a row (see `renderRow`), so a value that ticks every second no longer rebuilds the whole
68
+ * table.
69
+ *
70
+ * Whether a render concerns every row is decided in `render` and not by a flag that the caller
71
+ * sets: React may batch the render of a state change with a `setState` (a click) or with new
72
+ * props, and a flag would then keep every row as it was, although the selection has changed.
73
+ */
74
+ renderEpoch = 0;
75
+ /**
76
+ * Set by every `forceUpdate()`: something changed that is not visible in `state` or `props` (a
77
+ * rebuilt tree, other column widths), so every row has to be built again. Only
78
+ * `updateRowsOnly` renders without it
79
+ */
80
+ rowsInvalid = true;
81
+ /** `state` and `props` of the last render: a render with the same two cannot concern every row */
82
+ lastRenderedState = null;
83
+ lastRenderedProps = null;
84
+ /**
85
+ * row id -> the states the row showed when it was built last: its own value and the status
86
+ * states of a device (online, offline, error). The subscriptions that are still needed are
87
+ * collected from the rows of the window (see `checkUnsubscribes`) - a row that is served from
88
+ * the memo is not built again, but its entry here is still valid
89
+ */
90
+ rowStates = {};
91
+ /**
92
+ * state id -> the rows that show this state although it is not their own: a device shows the
93
+ * status of its `statusStates`. A change of such a state has to render those rows as well
94
+ */
95
+ stateDependents = {};
96
+ /**
97
+ * Rows that are rendered above and below the visible area, so that scrolling does not run into
98
+ * a gap before the next render
99
+ */
100
+ static OVERSCAN_ROWS = 12;
101
+ /** Every row of the table in the order in which it is shown (see `flattenItems`) */
102
+ flatRows = [];
103
+ /**
104
+ * Where every row of `flatRows` begins, in pixels. One entry longer than `flatRows`: the last
105
+ * one is the height of the whole table
106
+ */
107
+ rowOffsets = [0];
108
+ /** A row measured another height than `rowOffsets` expected: calculate them again */
109
+ rowOffsetsInvalid = false;
110
+ /** id -> the height the row really had. Only rows that are not `ROW_HEIGHT` high end up here */
111
+ measuredHeights = {};
112
+ /** Scroll position and height of the table container, the two values the window is cut from */
113
+ scrollTop = 0;
114
+ viewportHeight = 0;
115
+ scrollFrame = null;
116
+ /** First and last row of the last rendered window, to notice that scrolling moved it */
117
+ windowFirst = 0;
118
+ windowLast = 0;
119
+ /** id -> how often the state of this row has changed. Part of the memo key of the row */
120
+ rowStateVersion = {};
121
+ stateVersionCounter = 0;
63
122
  selectFirst;
64
123
  /** Last navigation that was applied from `navigateTo` or reported via `onNavigateTo` (loop guard). */
65
124
  lastNav = null;
@@ -110,7 +169,6 @@ export class ObjectBrowserClass extends Component {
110
169
  columnsVisibility = {};
111
170
  changedIds = null;
112
171
  contextMenu = null;
113
- recordStates = new Set();
114
172
  styles = {};
115
173
  expertMode = false;
116
174
  customColumnDialog = null;
@@ -664,6 +722,7 @@ export class ObjectBrowserClass extends Component {
664
722
  window.addEventListener('keydown', this.onKeyPress, true);
665
723
  window.addEventListener('keyup', this.onKeyPress, true);
666
724
  this.observeContainerWidth();
725
+ this.measureRenderedRows();
667
726
  // Inform dialog that all objects are loaded
668
727
  if (this.props.onAllLoaded) {
669
728
  setTimeout(() => {
@@ -695,6 +754,10 @@ export class ObjectBrowserClass extends Component {
695
754
  this.resizeObserver?.disconnect();
696
755
  this.resizeObserver = null;
697
756
  this.observedContainer = null;
757
+ if (this.scrollFrame !== null) {
758
+ window.cancelAnimationFrame(this.scrollFrame);
759
+ this.scrollFrame = null;
760
+ }
698
761
  // the timers must not fire after the component was destroyed
699
762
  if (this.unsubscribeTimer) {
700
763
  clearTimeout(this.unsubscribeTimer);
@@ -868,14 +931,33 @@ export class ObjectBrowserClass extends Component {
868
931
  return null;
869
932
  }
870
933
  }
934
+ /**
935
+ * Remember that a row shows a state. Called while the row is built (see `renderLeaf`)
936
+ *
937
+ * @param rowId the row
938
+ * @param stateId the state it shows: its own or one of its status states
939
+ */
940
+ recordState(rowId, stateId) {
941
+ (this.rowStates[rowId] ||= new Set()).add(stateId);
942
+ if (stateId !== rowId) {
943
+ (this.stateDependents[stateId] ||= new Set()).add(rowId);
944
+ }
945
+ }
871
946
  checkUnsubscribes() {
947
+ // Only the states of the rows in the window are needed. Rows that scrolled away are not
948
+ // rendered anymore, so their states are unsubscribed as well
949
+ const needed = new Set();
950
+ for (let i = this.windowFirst; i < this.windowLast; i++) {
951
+ const rowId = this.flatRows[i]?.item.data.id;
952
+ this.rowStates[rowId]?.forEach(id => needed.add(id));
953
+ }
872
954
  // Remove unused subscriptions
873
955
  for (const id of [...this.subscribes]) {
874
- if (!this.recordStates.has(id)) {
956
+ if (!needed.has(id)) {
875
957
  this.unsubscribe(id);
958
+ delete this.stateDependents[id];
876
959
  }
877
960
  }
878
- this.recordStates.clear();
879
961
  }
880
962
  /**
881
963
  * Find an item.
@@ -933,11 +1015,15 @@ export class ObjectBrowserClass extends Component {
933
1015
  else {
934
1016
  delete this.states[id];
935
1017
  }
1018
+ // only this row has to be rendered again - and the rows that show this state as their status
1019
+ const version = ++this.stateVersionCounter;
1020
+ this.rowStateVersion[id] = version;
1021
+ this.stateDependents[id]?.forEach(rowId => (this.rowStateVersion[rowId] = version));
936
1022
  if (!this.pausedSubscribes) {
937
1023
  if (!this.statesUpdateTimer) {
938
1024
  this.statesUpdateTimer = setTimeout(() => {
939
1025
  this.statesUpdateTimer = null;
940
- this.forceUpdate();
1026
+ this.updateRowsOnly();
941
1027
  }, 300);
942
1028
  }
943
1029
  }
@@ -2023,11 +2109,27 @@ export class ObjectBrowserClass extends Component {
2023
2109
  }
2024
2110
  this.resizeObserver?.disconnect();
2025
2111
  this.observedContainer = container;
2026
- this.resizeObserver = new ResizeObserver(entries => this.setContainerWidth(entries[0].contentRect.width));
2112
+ this.resizeObserver = new ResizeObserver(entries => {
2113
+ // the height decides how many rows are rendered (see `getWindow`)
2114
+ this.setViewportHeight(entries[0].contentRect.height);
2115
+ this.setContainerWidth(entries[0].contentRect.width);
2116
+ });
2027
2117
  this.resizeObserver.observe(container);
2028
2118
  // measure immediately, so the first render after mounting shows the right columns
2119
+ this.setViewportHeight(container.clientHeight);
2029
2120
  this.setContainerWidth(container.clientWidth);
2030
2121
  }
2122
+ /** Store the height of the container: it decides how many rows the window holds */
2123
+ setViewportHeight(viewportHeight) {
2124
+ if (Math.abs(this.viewportHeight - viewportHeight) < 1) {
2125
+ return;
2126
+ }
2127
+ this.viewportHeight = viewportHeight;
2128
+ const { first, last } = this.getWindow();
2129
+ if (first !== this.windowFirst || last !== this.windowLast) {
2130
+ this.updateRowsOnly();
2131
+ }
2132
+ }
2031
2133
  /** Store the width class of the container if it changed */
2032
2134
  setContainerWidth(containerWidth) {
2033
2135
  const width = widthFromContainer(containerWidth, this.props.theme);
@@ -2036,6 +2138,139 @@ export class ObjectBrowserClass extends Component {
2036
2138
  this.setState({ containerWidth: width });
2037
2139
  }
2038
2140
  }
2141
+ /**
2142
+ * The height a row needs.
2143
+ *
2144
+ * A row with an alias is higher than the others, and what a row really measured beats the
2145
+ * estimate - the table has to know the height of rows it does not render at the moment,
2146
+ * otherwise the scrollbar does not fit the content.
2147
+ */
2148
+ rowHeight(item) {
2149
+ const id = item.data.id;
2150
+ // the root itself has no row, only its children have one
2151
+ if (!id) {
2152
+ return 0;
2153
+ }
2154
+ const measured = this.measuredHeights[id];
2155
+ if (measured) {
2156
+ return measured;
2157
+ }
2158
+ const common = this.objects[id]?.common;
2159
+ if (id.startsWith('alias.') && common?.alias?.id) {
2160
+ // see `tableRowAlias` and `tableRowAliasReadWrite` in styles.ts
2161
+ return typeof common.alias.id === 'object' ? ROW_HEIGHT + 22 : ROW_HEIGHT + 10;
2162
+ }
2163
+ return ROW_HEIGHT;
2164
+ }
2165
+ /** All rows in the order in which they are shown, and where each of them begins */
2166
+ buildFlatRows() {
2167
+ this.flatRows = this.root ? leaf.flattenItems(this, this.root) : [];
2168
+ this.calculateRowOffsets();
2169
+ }
2170
+ /** Where every row begins - again after a row measured another height than was expected */
2171
+ calculateRowOffsets() {
2172
+ this.rowOffsetsInvalid = false;
2173
+ const offsets = new Array(this.flatRows.length + 1);
2174
+ offsets[0] = 0;
2175
+ for (let i = 0; i < this.flatRows.length; i++) {
2176
+ offsets[i + 1] = offsets[i] + this.rowHeight(this.flatRows[i].item);
2177
+ }
2178
+ this.rowOffsets = offsets;
2179
+ }
2180
+ /** The row that covers this position (binary search over `rowOffsets`) */
2181
+ findRowAt(position) {
2182
+ let low = 0;
2183
+ let high = this.flatRows.length - 1;
2184
+ while (low < high) {
2185
+ const middle = (low + high + 1) >> 1;
2186
+ if (this.rowOffsets[middle] <= position) {
2187
+ low = middle;
2188
+ }
2189
+ else {
2190
+ high = middle - 1;
2191
+ }
2192
+ }
2193
+ return low;
2194
+ }
2195
+ /**
2196
+ * The rows that are rendered: the visible ones plus `OVERSCAN_ROWS` above and below.
2197
+ *
2198
+ * A tree with a few thousand open rows costs a fifth of a second of blocked main thread for
2199
+ * every change that concerns all rows - selecting a row, a filter, folding a node - because
2200
+ * every row is built anew. Only the rows around the visible area are rendered now, so that
2201
+ * work no longer grows with the size of the tree.
2202
+ */
2203
+ getWindow() {
2204
+ const count = this.flatRows.length;
2205
+ if (!count) {
2206
+ return { first: 0, last: 0 };
2207
+ }
2208
+ // until the container was measured (the very first render) render a screen full of rows
2209
+ const height = this.viewportHeight || 1_000;
2210
+ const first = Math.max(0, this.findRowAt(this.scrollTop) - ObjectBrowserClass.OVERSCAN_ROWS);
2211
+ const last = Math.min(count, this.findRowAt(this.scrollTop + height) + 1 + ObjectBrowserClass.OVERSCAN_ROWS);
2212
+ return { first, last };
2213
+ }
2214
+ /** The table was scrolled: move the window of rendered rows, at most once per frame */
2215
+ onTableScroll = () => {
2216
+ if (this.scrollFrame !== null) {
2217
+ return;
2218
+ }
2219
+ this.scrollFrame = window.requestAnimationFrame(() => {
2220
+ this.scrollFrame = null;
2221
+ const container = this.tableRef.current;
2222
+ if (!container) {
2223
+ return;
2224
+ }
2225
+ this.scrollTop = container.scrollTop;
2226
+ const { first, last } = this.getWindow();
2227
+ if (first !== this.windowFirst || last !== this.windowLast) {
2228
+ // scrolling changes no row, so the rows that stay in the window keep their output
2229
+ this.updateRowsOnly();
2230
+ }
2231
+ });
2232
+ };
2233
+ /**
2234
+ * Remember how high the rendered rows really are.
2235
+ *
2236
+ * Rows are not all `ROW_HEIGHT` high (an alias is higher, the focused row of the narrow view
2237
+ * has no fixed height at all), and a row outside the window cannot be measured - so every row
2238
+ * that was rendered once keeps its measured height for the calculation of the window.
2239
+ */
2240
+ measureRenderedRows() {
2241
+ const container = this.tableRef.current;
2242
+ if (!container) {
2243
+ return;
2244
+ }
2245
+ const heights = {};
2246
+ for (const child of Array.from(container.children)) {
2247
+ // the details panel of the narrow view follows its row and belongs to its height
2248
+ const detailsOf = child.dataset.detailsOf;
2249
+ if (detailsOf) {
2250
+ if (heights[detailsOf]) {
2251
+ heights[detailsOf] += child.offsetHeight;
2252
+ }
2253
+ continue;
2254
+ }
2255
+ // with drag and drop enabled the row sits inside a wrapper
2256
+ const row = (child.id ? child : child.firstElementChild);
2257
+ if (row?.id && row.offsetHeight) {
2258
+ heights[row.id] = row.offsetHeight;
2259
+ }
2260
+ }
2261
+ let changed = false;
2262
+ for (const id of Object.keys(heights)) {
2263
+ if (Math.abs((this.measuredHeights[id] || ROW_HEIGHT) - heights[id]) > 0.5) {
2264
+ this.measuredHeights[id] = heights[id];
2265
+ changed = true;
2266
+ }
2267
+ }
2268
+ if (changed) {
2269
+ // the rows kept their content, only the calculated positions move
2270
+ this.rowOffsetsInvalid = true;
2271
+ this.updateRowsOnly();
2272
+ }
2273
+ }
2039
2274
  /**
2040
2275
  * All column widths as CSS variables of the table container. The header and every row read the
2041
2276
  * widths from here (see `colWidth`), so they can never get out of sync, and the resizer can
@@ -2162,14 +2397,8 @@ export class ObjectBrowserClass extends Component {
2162
2397
  }
2163
2398
  if (event.code === 'ArrowUp' || event.code === 'ArrowDown') {
2164
2399
  event.preventDefault();
2165
- const ids = [];
2166
- // the first child is the header, it has no ID
2167
- this.tableRef.current?.childNodes.forEach((node) => {
2168
- const nodeId = node.id;
2169
- if (nodeId) {
2170
- ids.push(nodeId);
2171
- }
2172
- });
2400
+ // every row of the table, not only the rendered ones (see `getWindow`)
2401
+ const ids = this.flatRows.map(row => row.item.data.id).filter(id => !!id);
2173
2402
  const idx = ids.indexOf(selectedId);
2174
2403
  const newIdx = event.code === 'ArrowDown' ? idx + 1 : idx - 1;
2175
2404
  const newId = ids[newIdx] || selectedId;
@@ -2342,19 +2571,38 @@ export class ObjectBrowserClass extends Component {
2342
2571
  componentDidUpdate(prevProps) {
2343
2572
  // the table is rendered only after the objects are loaded
2344
2573
  this.observeContainerWidth();
2574
+ this.measureRenderedRows();
2345
2575
  if (this.tableRef.current && this.selectFirst) {
2346
2576
  this.scrollToItem(this.selectFirst);
2347
2577
  }
2348
2578
  this.reconcileNavigation(prevProps);
2349
2579
  }
2580
+ /**
2581
+ * Scrolls to a row.
2582
+ *
2583
+ * The row does not have to be rendered: only the rows around the visible area are (see
2584
+ * `getWindow`), so the position is taken from the flat list of all rows and the row is brought
2585
+ * into the middle of the table by scrolling there.
2586
+ */
2350
2587
  scrollToItem(id) {
2351
2588
  this.selectFirst = '';
2352
2589
  const node = window.document.getElementById(id);
2353
- node?.scrollIntoView({
2354
- behavior: 'auto',
2355
- block: 'center',
2356
- inline: 'center',
2357
- });
2590
+ if (node) {
2591
+ node.scrollIntoView({
2592
+ behavior: 'auto',
2593
+ block: 'center',
2594
+ inline: 'center',
2595
+ });
2596
+ return;
2597
+ }
2598
+ const container = this.tableRef.current;
2599
+ const index = this.flatRows.findIndex(row => row.item.data.id === id);
2600
+ if (!container || index === -1) {
2601
+ return;
2602
+ }
2603
+ const middle = this.rowOffsets[index] - Math.max(0, (this.viewportHeight - ROW_HEIGHT) / 2);
2604
+ container.scrollTop = Math.max(0, middle);
2605
+ // the scroll event moves the window, and the row is rendered with the next frame
2358
2606
  }
2359
2607
  onUpdate(valAck) {
2360
2608
  this.props.socket
@@ -2396,11 +2644,35 @@ export class ObjectBrowserClass extends Component {
2396
2644
  this.forceUpdate();
2397
2645
  }
2398
2646
  }
2647
+ /**
2648
+ * Renders the browser and every row again: whoever calls it has changed something that is not
2649
+ * visible in `state` or `props`, so the rows cannot keep their output
2650
+ */
2651
+ forceUpdate(callback) {
2652
+ this.rowsInvalid = true;
2653
+ super.forceUpdate(callback);
2654
+ }
2655
+ /**
2656
+ * Renders the browser, but keeps the output of every row that did not change: a state changed
2657
+ * (see `rowStateVersion`), the table was scrolled or a row measured another height
2658
+ */
2659
+ updateRowsOnly() {
2660
+ super.forceUpdate();
2661
+ }
2399
2662
  /**
2400
2663
  * The rendering method of this component.
2401
2664
  */
2402
2665
  render() {
2403
- this.recordStates.clear();
2666
+ // Every row is built again, if anything changed that concerns all of them: `state`, `props`
2667
+ // or whatever a `forceUpdate()` stands for. Otherwise the render only moved the window or
2668
+ // showed new values, and the rows keep their output (see `renderRow`)
2669
+ const fullRender = this.rowsInvalid || this.state !== this.lastRenderedState || this.props !== this.lastRenderedProps;
2670
+ this.rowsInvalid = false;
2671
+ this.lastRenderedState = this.state;
2672
+ this.lastRenderedProps = this.props;
2673
+ if (fullRender) {
2674
+ this.renderEpoch++;
2675
+ }
2404
2676
  if (this.unsubscribeTimer) {
2405
2677
  clearTimeout(this.unsubscribeTimer);
2406
2678
  }
@@ -2449,7 +2721,24 @@ export class ObjectBrowserClass extends Component {
2449
2721
  this.applyStoredWidths();
2450
2722
  this.calculateColumnsVisibility();
2451
2723
  }
2452
- const items = this.root ? this.renderItem(this.root, undefined) : null;
2724
+ // Only the rows around the visible area are rendered - see `getWindow`. The rows that are
2725
+ // not rendered are replaced by two spacers, so the scrollbar keeps the size of the table
2726
+ if (fullRender || !this.flatRows.length) {
2727
+ // a state change cannot add or remove a row, and scrolling does not either
2728
+ this.buildFlatRows();
2729
+ }
2730
+ else if (this.rowOffsetsInvalid) {
2731
+ this.calculateRowOffsets();
2732
+ }
2733
+ const { first, last } = this.getWindow();
2734
+ this.windowFirst = first;
2735
+ this.windowLast = last;
2736
+ const items = [];
2737
+ for (let i = first; i < last; i++) {
2738
+ items.push(leaf.renderRow(this, this.flatRows[i].item, this.flatRows[i].isExpanded));
2739
+ }
2740
+ const spaceAbove = this.rowOffsets[first];
2741
+ const spaceBelow = this.rowOffsets[this.flatRows.length] - this.rowOffsets[last];
2453
2742
  return (React.createElement(TabContainer, { key: this.props.dialogName, styles: { root: this.props.style } },
2454
2743
  React.createElement("style", null, `
2455
2744
  @keyframes newValueAnimation-light {
@@ -2487,9 +2776,11 @@ export class ObjectBrowserClass extends Component {
2487
2776
  `),
2488
2777
  React.createElement(TabHeader, null, this.getToolbar()),
2489
2778
  React.createElement(TabContent, null,
2490
- React.createElement(Box, { style: { ...styles.tableDiv, ...this.getColumnWidthVariables() }, ref: this.tableRef, onKeyDown: event => this.navigateKeyPress(event) },
2779
+ React.createElement(Box, { style: { ...styles.tableDiv, ...this.getColumnWidthVariables() }, ref: this.tableRef, onKeyDown: event => this.navigateKeyPress(event), onScroll: this.onTableScroll },
2491
2780
  toolbar.renderHeader(this),
2492
- items)),
2781
+ spaceAbove ? React.createElement("div", { style: { height: spaceAbove } }) : null,
2782
+ items,
2783
+ spaceBelow ? React.createElement("div", { style: { height: spaceBelow } }) : null)),
2493
2784
  contextMenu.renderContextMenu(this),
2494
2785
  dialogs.renderAliasMenu(this),
2495
2786
  dialogs.renderToast(this),