Skip to content

Commit e541ff8

Browse files
authored
Fix core grid bugs and rendering performance (#78)
Filtering: OR-only expression trees matched every record because the empty AND set was vacuously true; nullish cell values crashed string conditions. Fix the match logic and coerce nullish operands once in normalizeCase. Events: cancelable sorting/filtering fired after state was already mutated. Hand listeners a candidate copy and commit it onto the state-held object only when the event passes, preserving both object identity and the listener-can-modify contract. Emit sorted/filtered after the data view updates. Pipeline: concurrent async runs could resolve out of order. Sequence them with an epoch token; stale results are discarded and a rejected hook keeps the previous data state. Expose the documented but missing public columns setter and stop columnReducer from dropping sibling columns inside containers. Navigation: state was module-level and shared across grid instances; hidden columns were reachable and a nullish active node produced NaN row indices. Move state per instance and clamp over visible columns. Performance: key row rendering by column field so cells are reused, share a single Intl.Collator, memoize dot-path segments, and drive the scrollbar offset from a ResizeObserver coalesced into rAF instead of per-render measurement. Tests: 178 -> 227, coverage 98%.
1 parent acc2473 commit e541ff8

29 files changed

Lines changed: 1040 additions & 124 deletions

src/components/cell.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export default class IgcGridLiteCell<T extends object> extends LitElement {
7777
}
7878

7979
protected override update(props: PropertyValues<this>): void {
80-
if (props.has('adoptRootStyles')) {
80+
if (props.has('adoptRootStyles') || props.has('cellTemplate')) {
8181
this._adoptedStylesController.shouldAdoptStyles(this._shouldAdoptStyles);
8282
}
8383

src/components/column.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { GRID_COLUMN_TAG } from '../internal/tags.js';
77
import type {
88
BaseColumnConfiguration,
99
ColumnSortConfiguration,
10+
DataType,
1011
IgcCellContext,
1112
IgcHeaderContext,
1213
Keys,
@@ -44,7 +45,7 @@ export class IgcGridLiteColumn<T extends object = any>
4445

4546
/** The data type of the column's values. */
4647
@property({ attribute: 'data-type' })
47-
public dataType?: 'number' | 'string' | 'boolean' = 'string';
48+
public dataType?: DataType = 'string';
4849

4950
/** The header text of the column. */
5051
@property()

src/components/filter-row.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import type { FilterExpression, FilterOperation, OperandKeys } from '../operatio
2323
import { styles } from '../styles/filter-row/filter-row.css.js';
2424
import { all } from '../styles/themes/filtering-row-themes.js';
2525

26+
/** Number of filter expressions shown as chips before collapsing into a single counted chip. */
27+
const MAX_PREVIEW_CHIPS = 3;
28+
2629
type ExpressionChipProps<T> = {
2730
expression: FilterExpression<T>;
2831
selected: boolean;
@@ -135,12 +138,19 @@ export default class IgcFilterRow<T extends object> extends LitElement {
135138
const key = event.detail.value as OperandKeys<PropertyType<T, typeof this.column.field>>;
136139

137140
// XXX: Types
138-
this.expression.condition = (getFilterOperandsFor(this.column) as any)[key] as FilterOperation<
141+
const condition = (getFilterOperandsFor(this.column) as any)[key] as FilterOperation<
139142
PropertyType<T, keyof T>
140143
>;
141144

142-
if (this.input.value || this.expression.condition.unary) {
143-
this.filterController.filterWithEvent(this.expression, 'modify');
145+
if (this.input.value || condition.unary) {
146+
this.filterController.filterWithEvent(
147+
{ ...this.expression, condition },
148+
'modify',
149+
this.expression
150+
);
151+
} else {
152+
// Nothing to filter by yet - the condition is only staged in the UI.
153+
this.expression.condition = condition;
144154
}
145155

146156
this.requestUpdate();
@@ -156,9 +166,11 @@ export default class IgcFilterRow<T extends object> extends LitElement {
156166
: 'add';
157167

158168
if (shouldUpdate) {
159-
this.expression.searchTerm = value as any;
160-
161-
this.filterController.filterWithEvent(this.expression, type);
169+
this.filterController.filterWithEvent(
170+
{ ...this.expression, searchTerm: value as any },
171+
type,
172+
this.expression
173+
);
162174
} else {
163175
this.#removeExpression(this.expression);
164176
}
@@ -206,8 +218,9 @@ export default class IgcFilterRow<T extends object> extends LitElement {
206218
return async (e: Event) => {
207219
e.stopPropagation();
208220

209-
expression.criteria = expression.criteria === 'and' ? 'or' : 'and';
210-
this.filterController.filterWithEvent(expression, 'modify');
221+
const criteria = expression.criteria === 'and' ? 'or' : 'and';
222+
223+
this.filterController.filterWithEvent({ ...expression, criteria }, 'modify', expression);
211224
this.requestUpdate();
212225
};
213226
}
@@ -385,8 +398,8 @@ export default class IgcFilterRow<T extends object> extends LitElement {
385398
protected renderFilterState(column: ColumnConfiguration<T>) {
386399
const state = this.filterController.get(column.field);
387400

388-
const partial = state && state.length < 3;
389-
const hidden = state && state.length >= 3;
401+
const partial = state && state.length < MAX_PREVIEW_CHIPS;
402+
const hidden = state && state.length >= MAX_PREVIEW_CHIPS;
390403

391404
const open = () => {
392405
this.column = column;

src/components/grid.ts

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,16 @@ import IgcGridLiteHeaderRow from './header-row.js';
4040
import IgcGridLiteRow from './row.js';
4141
import IgcVirtualizer from './virtualizer.js';
4242

43-
/** Column reducer matching either direct column element or one nested in container */
43+
/** Column reducer matching either a direct column element or every one nested in a container */
4444
function columnReducer<T extends Element>(acc: T[], el: T): T[] {
4545
const tag = IgcGridLiteColumn.tagName;
46-
const column = el.matches(tag) ? el : el.querySelector(tag);
47-
if (column) acc.push(column as unknown as T);
46+
47+
if (el.matches(tag)) {
48+
acc.push(el);
49+
return acc;
50+
}
51+
52+
acc.push(...(Array.from(el.querySelectorAll(tag)) as unknown as T[]));
4853
return acc;
4954
}
5055

@@ -193,6 +198,23 @@ export class IgcGridLite<T extends object = any> extends EventEmitterBase<IgcGri
193198
@state()
194199
protected _dataState: T[] = [];
195200

201+
/** Monotonic token identifying the most recently started pipeline run. */
202+
private _pipelineEpoch = 0;
203+
204+
private _pipelineTask: Promise<void> = Promise.resolve();
205+
206+
/**
207+
* Resolves when the most recently started pipeline run has settled and its result
208+
* is rendered. Consumed by the sort/filter controllers so that `sorted`/`filtered`
209+
* are emitted against an up to date {@link IgcGridLite.dataView}.
210+
*
211+
* @internal
212+
*/
213+
public get _pipelineComplete(): Promise<void> {
214+
// The `updateComplete` hop lets a just-requested PIPELINE update install its task first.
215+
return this.updateComplete.then(() => this._pipelineTask);
216+
}
217+
196218
@property({ type: Boolean, reflect: true, attribute: 'adopt-root-styles' })
197219
public adoptRootStyles = false;
198220

@@ -258,7 +280,7 @@ export class IgcGridLite<T extends object = any> extends EventEmitterBase<IgcGri
258280
*/
259281
@property({ attribute: false })
260282
public get sortingExpressions(): SortingExpression<T>[] {
261-
return Array.from(this._stateController.sorting.state.values());
283+
return Array.from(this._stateController.sorting.state.values(), (expr) => ({ ...expr }));
262284
}
263285

264286
/**
@@ -278,9 +300,25 @@ export class IgcGridLite<T extends object = any> extends EventEmitterBase<IgcGri
278300
*/
279301
@property({ attribute: false })
280302
public get filterExpressions(): FilterExpression<T>[] {
281-
return this._stateController.filtering.state.values.flatMap((each) => each.all);
303+
return this._stateController.filtering.state.values.flatMap((each) =>
304+
each.all.map((expr) => ({ ...expr }))
305+
);
306+
}
307+
308+
/**
309+
* Sets the column configuration for the grid.
310+
*
311+
* @remarks
312+
* Passing an empty collection resets the columns, which - with `autoGenerate` -
313+
* lets the next data source binding re-generate them.
314+
*/
315+
public set columns(configuration: ColumnConfiguration<T>[]) {
316+
this._stateController.setColumnConfiguration(asArray(configuration));
282317
}
283318

319+
/**
320+
* Returns the column configuration of the grid.
321+
*/
284322
public get columns(): ColumnConfiguration<T>[] {
285323
return this._stateController.columns.map((col) => ({ ...col }));
286324
}
@@ -323,9 +361,35 @@ export class IgcGridLite<T extends object = any> extends EventEmitterBase<IgcGri
323361
}
324362
}
325363

364+
/**
365+
* NOTE: The `PIPELINE` sentinel resolves to this method's own name, which is what makes
366+
* the `@watch` comparison (`undefined !== <method>`) fire on every requested update.
367+
* Renaming the method silently disables the pipeline.
368+
*/
326369
@watch(PIPELINE)
327-
protected async pipeline() {
328-
this._dataState = await this._dataController.apply([...this.data], this._stateController);
370+
protected pipeline(): void {
371+
this._pipelineTask = this._runPipeline();
372+
}
373+
374+
/** Runs the data operations, discarding a result superseded by a newer run. */
375+
private async _runPipeline(): Promise<void> {
376+
const epoch = ++this._pipelineEpoch;
377+
378+
try {
379+
const state = await this._dataController.apply([...this.data], this._stateController);
380+
381+
if (epoch !== this._pipelineEpoch) {
382+
return;
383+
}
384+
385+
this._dataState = state;
386+
} catch (e) {
387+
// A failing hook must not blank the grid - keep the previous data state and report.
388+
// biome-ignore lint/suspicious/noConsole: the pipeline hooks are user code; swallowing their errors hides bugs
389+
console.error(e);
390+
}
391+
392+
await this.updateComplete;
329393
}
330394

331395
constructor() {
@@ -371,10 +435,10 @@ export class IgcGridLite<T extends object = any> extends EventEmitterBase<IgcGri
371435
* Performs a filter operation in the grid based on the passed expression(s).
372436
*/
373437
public filter(config: FilterExpression<T> | FilterExpression<T>[]): void {
374-
const expressions = asArray(config).filter((expr) => {
375-
const column = this.getColumn(expr.key);
376-
return column !== undefined;
377-
});
438+
// Copies - the caller's expression objects must not be rewritten.
439+
const expressions = asArray(config)
440+
.filter((expr) => this.getColumn(expr.key) !== undefined)
441+
.map((expr) => ({ ...expr }));
378442

379443
for (const expr of expressions) {
380444
if (!isString(expr.condition)) {

src/components/header-row.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,14 @@ export default class IgcGridLiteHeaderRow<T extends object> extends LitElement {
4747
this._state?.filtering.setActiveColumn(header?.column);
4848
}
4949

50-
protected override shouldUpdate(props: PropertyValues<this>): boolean {
50+
protected override willUpdate(props: PropertyValues<this>): void {
51+
// Column configuration is passed by reference, so the headers have to be
52+
// refreshed explicitly whenever the row itself updates.
5153
for (const header of this.headers) {
5254
header.requestUpdate();
5355
}
5456

55-
return super.shouldUpdate(props);
57+
super.willUpdate(props);
5658
}
5759

5860
protected override render() {
@@ -62,7 +64,7 @@ export default class IgcGridLiteHeaderRow<T extends object> extends LitElement {
6264
return html`
6365
${repeat(
6466
columns,
65-
(column) => column,
67+
(column) => column.field,
6668
(column) => html`
6769
<igc-grid-lite-header
6870
part=${partMap({ filtered: column.field === filterRow?.column?.field })}

src/components/header.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export default class IgcGridLiteHeader<T extends object> extends LitElement {
6767
}
6868

6969
protected override update(props: PropertyValues<this>): void {
70-
if (props.has('adoptRootStyles')) {
70+
if (props.has('adoptRootStyles') || props.has('column')) {
7171
this._adoptedStylesController.shouldAdoptStyles(
7272
this.adoptRootStyles && this.column.headerTemplate != null
7373
);

src/components/row.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export default class IgcGridLiteRow<T extends object> extends LitElement {
5151
return html`
5252
${repeat(
5353
columns,
54-
(column) => column,
54+
(column) => column.field,
5555
(column) => html`
5656
<igc-grid-lite-cell
5757
part="cell"

src/controllers/dom.ts

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,37 @@
11
import type { ReactiveController } from 'lit';
22
import type { StyleInfo } from 'lit/directives/style-map.js';
3+
import type IgcVirtualizer from '../components/virtualizer.js';
34
import { registerGridIcons } from '../internal/icon-registry.js';
45
import type { GridHost } from '../internal/types.js';
56
import { applyColumnWidths } from '../internal/utils.js';
67
import type { StateController } from './state.js';
78

9+
const SCROLLBAR_OFFSET_VAR = '--scrollbar-offset';
10+
const VISIBILITY_CHANGED = 'visibilityChanged';
11+
812
class GridDOMController<T extends object> implements ReactiveController {
913
protected readonly _host: GridHost<T>;
1014
protected readonly _state: StateController<T>;
1115

16+
/** The virtualizer currently being tracked for scrollbar changes. */
17+
#observed?: IgcVirtualizer;
18+
#resizeObserver?: ResizeObserver;
19+
20+
/** Last written offset in pixels. -1 marks "never measured". */
21+
#scrollOffset = -1;
22+
#pendingFrame?: number;
23+
24+
#onScrollbarChange = (): void => {
25+
if (this.#pendingFrame !== undefined) {
26+
return;
27+
}
28+
29+
this.#pendingFrame = requestAnimationFrame(() => {
30+
this.#pendingFrame = undefined;
31+
this.#applyScrollOffset();
32+
});
33+
};
34+
1235
constructor(host: GridHost<T>, state: StateController<T>) {
1336
this._host = host;
1437
this._state = state;
@@ -21,27 +44,60 @@ class GridDOMController<T extends object> implements ReactiveController {
2144
registerGridIcons();
2245
this.setGridColumnSizes();
2346

47+
// The virtualizer is part of the host template - wait for it before observing.
2448
this._host.updateComplete.then(() => {
25-
this._state.virtualizer?.addEventListener(
26-
'visibilityChanged',
27-
() => {
28-
this.setScrollOffset();
29-
},
30-
{ once: true }
31-
);
49+
this.#observeVirtualizer();
3250
});
3351
}
3452

53+
public hostDisconnected(): void {
54+
if (this.#pendingFrame !== undefined) {
55+
cancelAnimationFrame(this.#pendingFrame);
56+
this.#pendingFrame = undefined;
57+
}
58+
59+
this.#resizeObserver?.disconnect();
60+
this.#resizeObserver = undefined;
61+
62+
this.#observed?.removeEventListener(VISIBILITY_CHANGED, this.#onScrollbarChange);
63+
this.#observed = undefined;
64+
}
65+
3566
public hostUpdate(): void {
36-
this.setScrollOffset();
3767
this.setGridColumnSizes();
3868
}
3969

40-
public setScrollOffset(): void {
41-
const size = this._state.virtualizer
42-
? this._state.virtualizer.offsetWidth - this._state.virtualizer.clientWidth
43-
: 0;
44-
this._host.style.setProperty('--scrollbar-offset', `${size}px`);
70+
/**
71+
* Watches the virtualizer for anything which can toggle its scrollbar: a new
72+
* visible range, or a content box resize - the scrollbar itself shrinks the
73+
* content box. Keeping the measurement here avoids a forced layout on every
74+
* host update.
75+
*/
76+
#observeVirtualizer(): void {
77+
const virtualizer = this._state.virtualizer;
78+
79+
if (!virtualizer || virtualizer === this.#observed) {
80+
return;
81+
}
82+
83+
this.#observed = virtualizer;
84+
virtualizer.addEventListener(VISIBILITY_CHANGED, this.#onScrollbarChange);
85+
86+
this.#resizeObserver = new ResizeObserver(this.#onScrollbarChange);
87+
this.#resizeObserver.observe(virtualizer);
88+
}
89+
90+
/** Writes the scrollbar offset CSS variable, but only when the measurement changed. */
91+
#applyScrollOffset(): void {
92+
const virtualizer = this.#observed;
93+
const offset = virtualizer ? virtualizer.offsetWidth - virtualizer.clientWidth : 0;
94+
95+
if (offset === this.#scrollOffset) {
96+
return;
97+
}
98+
99+
this.#scrollOffset = offset;
100+
this._host.style.setProperty(SCROLLBAR_OFFSET_VAR, `${offset}px`);
45101
}
46102

47103
protected setGridColumnSizes(): void {

0 commit comments

Comments
 (0)