Commit b92bd3d6b7 for handsontable.com
commit b92bd3d6b7502548c7eaabf2ead8d0d378358f69
Author: Artur Mędrygał <artur.medrygal@handsontable.com>
Date: Mon Sep 14 11:59:34 2026 +0200
DEV-2889: Fix Filters crash on data replace with an active filter (#13480)
* DEV-2889: Rewire the Filters observer on disable to fix a data-replace crash
* DEV-2889: Add changelog entry for PR #13480
* DEV-2889: Also drop the stale filter menu focus navigator on disable
* DEV-2889: Address review - fold test helpers into one page object, tighten changelog
(cherry picked from commit 6d089d3dbf210dc53f1cefc6fc92fd03dfb3bd6e)
diff --git a/.changelogs/13480.json b/.changelogs/13480.json
new file mode 100644
index 0000000000..f464bc0206
--- /dev/null
+++ b/.changelogs/13480.json
@@ -0,0 +1,8 @@
+{
+ "issuesOrigin": "private",
+ "title": "Fixed a `filters` plugin crash thrown when a cell in a filtered column was edited, or the data was replaced with a filter active, after `updateSettings()` was called with the `filters` option (as the React and Angular wrappers do on every update).",
+ "type": "fixed",
+ "issueOrPR": 13480,
+ "breaking": false,
+ "framework": "none"
+}
diff --git a/handsontable/src/plugins/filters/AGENTS.md b/handsontable/src/plugins/filters/AGENTS.md
index 7fb7b686b1..067da10bb9 100644
--- a/handsontable/src/plugins/filters/AGENTS.md
+++ b/handsontable/src/plugins/filters/AGENTS.md
@@ -13,6 +13,7 @@ This subsystem mixes physical and visual column indexes across an API boundary.
- `conditionCollection` registers its own column map `'ConditionCollection.filteringStates'` on `this.hot.columnIndexMapper` in its constructor and **unregisters it in `destroy()`**. Do not register a second map under that name.
- The plugin registers a row `TrimmingMap` (`filtersRowsMap`) via `this.hot.rowIndexMapper.registerMap()` in `enablePlugin()` and unregisters it in `disablePlugin()`. Filtered-out rows are trimmed (removed from the DataMap), not hidden — account for that when reading row state.
+- **`disablePlugin()` must tear down every instance it rebuilds behind an `if (!this.…)` guard, not just the collection.** Three fields form this family — `conditionCollection`, `conditionUpdateObserver`, and `#menuFocusNavigator` — and each is recreated in `enablePlugin()` only when the guard finds it empty. A disable that leaves any of them set strands it across the disable/enable cycle, because the guard then skips the rebuild while the components, collection and menu it referenced were all destroyed and recreated. The observer is the dangerous one: it holds its own reference to the collection (set at construction, plus the local hooks it subscribed to that instance), so a surviving observer bound to a `destroy()`-nulled collection drives `exportAllConditions()` into `filteringStates.getEntries()` on `null` → `Cannot read properties of null (reading 'getEntries')`. `#menuFocusNavigator` is the same family, one guard up (`filters.ts` `enablePlugin()`): its cached `focusableItems` keep pointing the Tab focus at detached component elements, so filter-menu keyboard navigation silently breaks — no crash. `disablePlugin()` therefore destroys+nulls the observer, destroys+nulls the collection, and sets `#menuFocusNavigator = undefined`, the way `destroy()` tears down the first two. This whole family is reachable whenever `updateSettings` carries the `filters` key (so `updatePlugin` runs disable+enable) — which the React and Angular wrappers do on every update, while the Vue 3 wrapper diffs each key against the current settings (`wrappers/vue3/src/helpers.ts`) and skips unchanged ones — followed by a data change (`afterUpdateData` on a second replace, or `afterChange` on a filtered cell) or a keyboard menu open. Do NOT "fix" the crash by null-guarding `exportAllConditions()` instead: that turns a loud crash into silent staleness (the value list stops refreshing) and hides the next occurrence. Coverage: `tests/e2e/filters-data-replace-cycle.spec.ts` (DEV-2889).
- Follow the standard plugin lifecycle: `super.enablePlugin()` last, `super.disablePlugin()` first. See the `handsontable-plugin-dev` skill.
## "Filter by value" list scope
diff --git a/handsontable/src/plugins/filters/filters.ts b/handsontable/src/plugins/filters/filters.ts
index 2a5ce24ac2..79f487a1e4 100644
--- a/handsontable/src/plugins/filters/filters.ts
+++ b/handsontable/src/plugins/filters/filters.ts
@@ -455,8 +455,21 @@ export class Filters extends BasePlugin {
component?.destroy();
this.components.set(key, null);
});
+ // Destroy and null the observer alongside the collection, the way `destroy()` tears down
+ // both. The observer holds its own reference to the collection, so leaving it bound to a
+ // destroyed collection strands it: `enablePlugin()` recreates the collection but skips the
+ // observer it still holds, and the next data change reads the destroyed collection and throws
+ // (DEV-2889). Order between the two does not matter - neither `destroy()` fires a hook the
+ // other listens to.
+ this.conditionUpdateObserver?.destroy();
+ this.conditionUpdateObserver = null;
this.conditionCollection?.destroy();
this.conditionCollection = null;
+ // Drop the focus navigator too. It caches `focusableItems` built from the component elements
+ // just destroyed, and its `enablePlugin()` rebuild sits behind an `if (!this.#menuFocusNavigator)`
+ // guard - so a surviving instance keeps pointing the Tab focus at detached elements and blocks
+ // a fresh one. Same stale-reference family as the collection/observer above (DEV-2889).
+ this.#menuFocusNavigator = undefined;
this.hot.rowIndexMapper.unregisterMap(this.pluginName ?? '');
}
diff --git a/tests/e2e/filters-data-replace-cycle.spec.ts b/tests/e2e/filters-data-replace-cycle.spec.ts
new file mode 100644
index 0000000000..f67af0a281
--- /dev/null
+++ b/tests/e2e/filters-data-replace-cycle.spec.ts
@@ -0,0 +1,106 @@
+import { test, expect } from '../fixtures/test';
+import { FiltersValueListPage } from '../fixtures/pages/FiltersValueListPage';
+
+/**
+ * Regression coverage for DEV-2889.
+ *
+ * Applying a filter and then replacing the grid's data — while re-sending the `filters` option, the
+ * way the React and Angular wrappers re-send their whole settings object on every update (the Vue 3
+ * wrapper diffs each key against the current settings and skips unchanged ones) — used to leave the
+ * Filters plugin's `ConditionUpdateObserver` bound to a `ConditionCollection` that `disablePlugin()`
+ * had already destroyed. `disablePlugin()` destroyed and nulled the collection but never touched the
+ * observer, and `enablePlugin()` skipped rebuilding an observer it still held. The next time a data
+ * change drove that stale observer, it read the destroyed collection and threw:
+ *
+ * TypeError: Cannot read properties of null (reading 'getEntries')
+ * at ConditionCollection.exportAllConditions
+ * at ConditionUpdateObserver.updateStatesAtColumn
+ *
+ * A single filter-then-replace cycle is fine: the observer is only stranded once the plugin is
+ * disabled/enabled, and it is not read until a later data change. The crash needs the sequence twice.
+ * Two data-change paths reach the stale observer, and both are covered below: `afterUpdateData`
+ * (a second data replacement) and `afterChange` (editing a cell in a filtered column).
+ *
+ * The crash surfaces as a rejection of the `page.evaluate` that triggers it — the throw is synchronous
+ * inside a hook dispatched within `updateSettings` / `setDataAtCell`, so the awaited `replaceData()` /
+ * `setCellValue()` call fails the test directly, which is the load-bearing assertion here.
+ */
+test.describe('Filters — replacing data with an active filter', () => {
+ test('does not crash when data is replaced twice with an active filter', async({ page, theme, bundle }) => {
+ const grid = new FiltersValueListPage(page, theme, bundle);
+
+ await grid.goto();
+
+ // Cycle 1: filter, then replace the data.
+ await grid.addFilter(0, 'eq', ['Alice']);
+ expect(await grid.visibleRowCount()).toBe(1);
+ await grid.replaceData([['Alice', 'Red'], ['Bob', 'Green'], ['Charlie', 'Blue']]);
+
+ // Cycle 2: filter again, then replace the data again. The second replacement drives the stale
+ // observer through `afterUpdateData`, which is where the crash was thrown. The row-count check
+ // matters: that path only reaches the observer while a condition is active (`#onAfterUpdateData`
+ // returns early when `getFilteredColumns()` is empty), so a condition that stopped landing after
+ // a replace would make this test pass on unfixed code.
+ await grid.addFilter(0, 'eq', ['Bob']);
+ expect(await grid.visibleRowCount()).toBe(1);
+ await grid.replaceData([['Xavier', 'Red'], ['Yara', 'Green'], ['Zoe', 'Blue']]);
+
+ // The plugin is still functional: a fresh filter after the second replacement trims correctly.
+ await grid.addFilter(0, 'eq', ['Zoe']);
+ expect(await grid.visibleRowCount()).toBe(1);
+ });
+
+ test('does not crash when a cell in a filtered column is edited after a data replace',
+ async({ page, theme, bundle }) => {
+ const grid = new FiltersValueListPage(page, theme, bundle);
+
+ await grid.goto();
+
+ await grid.addFilter(0, 'eq', ['Alice']);
+ await grid.replaceData([['Alice', 'Red'], ['Bob', 'Green'], ['Charlie', 'Blue']]);
+
+ // Re-apply a filter on the same column, then edit its one visible cell. The edit reaches the
+ // Filters plugin's `afterChange` handler, which refreshes the value component through the same
+ // observer — the second path into the stranded collection.
+ await grid.addFilter(0, 'eq', ['Bob']);
+ expect(await grid.visibleRowCount()).toBe(1);
+
+ await grid.setCellValue(0, 0, 'Bobby');
+
+ // The edit landed and the grid is still responsive.
+ await expect(grid.cell(0, 0)).toHaveText('Bobby');
+ });
+});
+
+/**
+ * The Filters plugin's menu focus navigator (`#menuFocusNavigator`) caches the component elements it
+ * moves the Tab focus between. Replacing the data while re-sending `filters` disables and enables the
+ * plugin, which destroys and recreates those components — so the navigator has to be rebuilt with
+ * them, or Tab focus keeps pointing at detached elements and never reaches the live value list. This
+ * is the same stale-reference family as the crash above; `disablePlugin()` now drops the navigator
+ * too.
+ */
+test('keeps filter-menu keyboard focus navigation working after a data replace',
+ async({ page, theme, bundle }) => {
+ const grid = new FiltersValueListPage(page, theme, bundle);
+
+ await grid.goto();
+
+ // Baseline: from the keyboard, Tab into the value list and focus an item.
+ await grid.openMenuWithKeyboard(0, 0);
+ await page.keyboard.press('Tab');
+ await page.keyboard.press('Tab');
+ await page.keyboard.press('ArrowDown');
+ await expect(grid.focusedListItems()).toHaveCount(1);
+ await grid.escapeMenu();
+
+ // Replace the data while re-sending `filters`, the shape the React and Angular wrappers commit.
+ await grid.replaceData([['Xavier', 'Red'], ['Yara', 'Green'], ['Zoe', 'Blue']]);
+
+ // The same keyboard path must still reach the (rebuilt) value list.
+ await grid.openMenuWithKeyboard(0, 0);
+ await page.keyboard.press('Tab');
+ await page.keyboard.press('Tab');
+ await page.keyboard.press('ArrowDown');
+ await expect(grid.focusedListItems()).toHaveCount(1);
+ });
diff --git a/tests/fixtures/demo/filters-value-list.html b/tests/fixtures/demo/filters-value-list.html
index ec9d0d5458..98fc47492f 100644
--- a/tests/fixtures/demo/filters-value-list.html
+++ b/tests/fixtures/demo/filters-value-list.html
@@ -56,7 +56,8 @@
td.setAttribute('data-testid', `cell-${row}-${col}`);
};
- new Handsontable(container, {
+ // Exposed so a spec can drive the documented `filter()` recipe directly.
+ window.hot = new Handsontable(container, {
data: [
['Alice', 'Red'],
['Bob', 'Green'],
diff --git a/tests/fixtures/pages/FiltersValueListPage.ts b/tests/fixtures/pages/FiltersValueListPage.ts
index f7c9a8dd5a..3423bf40f4 100644
--- a/tests/fixtures/pages/FiltersValueListPage.ts
+++ b/tests/fixtures/pages/FiltersValueListPage.ts
@@ -56,6 +56,35 @@ export class FiltersValueListPage {
await expect(this.valueList.first()).toBeVisible();
}
+ /** The list items that currently carry the grid's focus highlight. */
+ focusedListItems(): Locator {
+ return this.menu.locator('.htUIMultipleSelect .ht_master .htCore tbody td.current');
+ }
+
+ /**
+ * Opens the dropdown menu of the given column from the keyboard, the way a user reaches it without
+ * the mouse. The keyboard path keeps the by-value list alive across menu openings, which the click
+ * path does not, so a focus ring left in the list survives into the next opening.
+ *
+ * @param {number} row Visual row index to select first.
+ * @param {number} col Visual column index whose menu opens.
+ */
+ async openMenuWithKeyboard(row: number, col: number): Promise<void> {
+ await this.page.evaluate(([r, c]) => (window as unknown as {
+ hot: { selectCell: (row: number, column: number) => void }
+ }).hot.selectCell(r, c), [row, col]);
+ await this.page.keyboard.press('Alt+Shift+ArrowDown');
+
+ await expect(this.menu).toBeVisible();
+ await expect(this.valueList.first()).toBeVisible();
+ }
+
+ /** Close the menu with the Escape key and wait for it to go away. */
+ async escapeMenu(): Promise<void> {
+ await this.page.keyboard.press('Escape');
+ await expect(this.menu).toBeHidden();
+ }
+
/** Confirm the menu with the "OK" button and wait for it to close. */
async confirmMenu(): Promise<void> {
await this.menu.locator('.htUIButtonOK input').click();
@@ -119,4 +148,52 @@ export class FiltersValueListPage {
await checkbox.click();
await expect(checkbox).not.toBeChecked();
}
+
+ /**
+ * Add a filter condition to a column through the plugin API and apply it, the way the documented
+ * `filter()` recipe does. This is the API counterpart to the menu-driven `applyCondition()`.
+ *
+ * @param {number} column The visual column index.
+ * @param {string} name The condition short name (e.g. `eq`, `by_value`).
+ * @param {Array} args The condition arguments.
+ */
+ async addFilter(column: number, name: string, args: unknown[]): Promise<void> {
+ await this.page.evaluate(({ column: col, name: conditionName, args: conditionArgs }) => {
+ const plugin = window.hot.getPlugin('filters');
+
+ plugin.addCondition(col, conditionName, conditionArgs);
+ plugin.filter();
+ }, { column, name, args });
+ }
+
+ /**
+ * Replace the grid's source data while keeping the `filters` option in the payload, exactly as the
+ * React and Angular wrappers re-send their whole settings object on every update. Passing `filters`
+ * is what makes `updateSettings` run the Filters plugin's `updatePlugin` (disable + enable) cycle.
+ *
+ * @param {Array} data The new source data.
+ */
+ async replaceData(data: unknown[][]): Promise<void> {
+ await this.page.evaluate((newData) => {
+ window.hot.updateSettings({ data: newData, filters: true });
+ }, data);
+ }
+
+ /**
+ * Write a value into a cell through the API, which fires `afterChange`.
+ *
+ * @param {number} row The visual row index.
+ * @param {number} col The visual column index.
+ * @param {string} value The new value.
+ */
+ async setCellValue(row: number, col: number, value: string): Promise<void> {
+ await this.page.evaluate(({ row: r, col: c, value: v }) => {
+ window.hot.setDataAtCell(r, c, v);
+ }, { row, col, value });
+ }
+
+ /** The number of rows the grid currently shows (source rows minus the filtered-out ones). */
+ async visibleRowCount(): Promise<number> {
+ return this.page.evaluate(() => window.hot.countRows());
+ }
}