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());
+  }
 }