Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@

---

## [Unreleased]

### Added

- **Dev warning for re-created `localeText` functions.** A `localeText` whose function entries (`paginationOf`, `rowSelectLabel`, …) are written inline is a new object on every render, and every rendered row reads the locale, so each parent render re-rendered all of them. The grid now warns once in development, naming the entries, after three renders in a row re-create the same function. Behaviour is unchanged: the functions are not frozen, so one that reads new state (a language switch) still updates the rows.

## [3.10.1] — 2026-10-13

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ Measured in Chromium on an Apple M1, 1,000,000 rows, from the event to the end o

3.9.0 keeps rows memoised: selecting a row, starting or committing an edit, and vertical scrolling re-render only the rows they change (a click re-rendered every visible row, now one). React's own work per interaction is about 3–5 ms at 100k rows.

To keep this, change rows by passing new row objects (never mutate them), keep `columns` and `localeText` shallowly stable (inline objects of plain values are fine; define function entries outside the render). Handlers such as `onRowClick` may be inline: the grid passes rows its own stable handlers.
To keep this, change rows by passing new row objects (never mutate them), keep `columns` and `localeText` shallowly stable (inline objects of plain values are fine; define function entries outside the render; in development the grid warns when it sees the same function re-created render after render). Handlers such as `onRowClick` may be inline: the grid passes rows its own stable handlers.

### Method

Expand Down
5 changes: 3 additions & 2 deletions docs/superpowers/plans/2026-10-13-audit-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ Release plan:
| B5 | Med | Date paste into ISO date-time string lands on previous day west of UTC | `lib/utils/parsing.ts:150-151` | 3.7.1 | ✅ | zoned current → local time of day on pasted day, written as UTC; tested in 3 TZs |
| B6 | Med | List view reads undeclared `--ogx-grid-cell-text`; selection colours bypass theme | `ListView.css:82` | 3.7.1 | ✅ | list view uses `--ogx-grid-row-*` vars; old `--ogx-selection-*` still win |
| B7 | Low–Med | Duplicate row ids silently drop rows (no dev warning) | row store | later | ✅ | Already handled since 3.0: dev warning naming the ids (`buildRowsState`, tested in `DataGrid.state.test.tsx`); the audit overstated it. Warns once per page |
| B8 | Low | Quick filter stale on in-place row mutation | quick-filter cache | later | ⏸ | Inherent to the per-row text cache; documented in `docs/features/filtering.md` |
| B9 | Low | Quick filter stale when a formatter closure changes | quick-filter cache | later | ⏸ | Inherent to the cache; documented (pass new column definitions) |
| B8 | Low | Quick filter stale on in-place row mutation | quick-filter cache | — | 🚫 | Won't fix (2026-10-16): the cache is keyed by row object; detecting mutation means re-reading every cell per keystroke (the 1.6–2 s cold search each time). Documented in `docs/features/filtering.md` |
| B9 | Low | Quick filter stale when a formatter closure changes | quick-filter cache | — | 🚫 | Won't fix (2026-10-16), same reason as B8; documented (pass new column definitions) |
| B10 | Low | `bigint` loses precision in sort | `lib/utils/sorting/index.ts:45` | later | ✅ | Exact re-ranking (`rankNumbersExactly`, `compareExact`) when a column has an unsafe bigint; generic path tie-breaks by `big` |
| B11 | Low | Number filters accept `0x10`, `Infinity` | `lib/utils/values.ts:96-101` | later | ✅ | `toNumber` rejects radix prefixes and non-finite text; new `values.test.ts` |

Expand Down Expand Up @@ -81,3 +81,4 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil
- 2026-10-13 — Time-sliced pipeline closed (user agreed): proposal status updated, branch archived as `archive/timesliced-pipeline-2026-10-12`, both agent worktrees removed. Backlog under the feature hold is now empty except R-items done; ready to lift the hold (ask user).
- 2026-10-13 — Release 3.10.0 via PR.
- 2026-10-13 — 3.10.1 work: Next.js smoke fixture; perf #5 select-all ~60–90 ms / deselect <50 ms (live-check markers), #2 grouping 540 → 190 ms (lazy leaf maps, index partition), #3 quick filter first search 2.7 → ~1.6–2 s (per-day date memo, shown-text lower-case cache). check:compiler caught a getter-in-hook regression during #2 (fixed before push).
- 2026-10-16 — B8/B9 closed as won't fix (by design). Inline-function `localeText`: freezing the functions would bring back B9-style stale row labels, so added a dev-only warning instead (`useGridLocaleTextWarning`, in `GridRoot`). Branch `fix/localetext-functions`.
286 changes: 286 additions & 0 deletions lib/components/Cell/GridEditInputCell.select.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { userEvent } from 'vitest/browser';
import type { ReactNode } from 'react';
import { DataGrid } from '../../index';
import type { GridColDef } from '../../types';
import '../../styles/opengridx.css';

// The singleSelect editor (a native <select>) in real Chromium, Firefox and WebKit: real focus,
// real clicks and real key presses. A native select's popup is drawn by the browser outside the
// page, so these tests cannot open it and pick from it; they drive the focused select with the
// keyboard and with Playwright's selectOption (which sets the value and fires input / change).

type Row = { id: number; name: string; role: string; team: string };

const ROWS: Row[] = [
{ id: 1, name: 'Ann', role: 'Editor', team: 'Red' },
{ id: 2, name: 'Bob', role: 'Admin', team: 'Blue' },
{ id: 3, name: 'Cid', role: 'Viewer', team: 'Green' },
];
const COLS: GridColDef<Row>[] = [
{ field: 'name', editable: true, width: 120 },
{ field: 'role', headerName: 'Role', editable: true, width: 140, type: 'singleSelect', valueOptions: ['Admin', 'Editor', 'Viewer'] },
{ field: 'team', editable: true, width: 120 },
];

const Bounded = ({ children }: { children: ReactNode }) => (
<div style={{ height: 300, width: 500, display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, minHeight: 0 }}>{children}</div>
</div>
);

const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const cellOf = (container: HTMLElement, rowIndex: number, field: string) =>
container.querySelector<HTMLElement>(`.ogx__rows [data-rowindex="${rowIndex}"] [data-field="${field}"]`);
const editorOf = (container: HTMLElement) => container.querySelector<HTMLSelectElement>('.ogx__edit-select');

function renderGrid() {
const processRowUpdate = vi.fn((r: Row) => r);
const { container } = render(<Bounded><DataGrid rows={ROWS} columns={COLS} height="100%" processRowUpdate={processRowUpdate} /></Bounded>);
return { container, processRowUpdate };
}

async function openByDoubleClick(container: HTMLElement): Promise<HTMLSelectElement> {
await expect.poll(() => cellOf(container, 0, 'role')).not.toBeNull();
await userEvent.dblClick(cellOf(container, 0, 'role')!);
await expect.poll(() => editorOf(container)).not.toBeNull();
const select = editorOf(container)!;
await expect.poll(() => document.activeElement).toBe(select);
return select;
}

/** Focuses the role cell with a click, then opens the editor with Enter. */
async function openByEnter(container: HTMLElement): Promise<HTMLSelectElement> {
await expect.poll(() => cellOf(container, 0, 'role')).not.toBeNull();
await userEvent.click(cellOf(container, 0, 'role')!);
await expect.poll(() => document.activeElement).toBe(cellOf(container, 0, 'role'));
await userEvent.keyboard('{Enter}');
await expect.poll(() => editorOf(container)).not.toBeNull();
const select = editorOf(container)!;
await expect.poll(() => document.activeElement).toBe(select);
return select;
}

afterEach(() => cleanup());

describe('singleSelect editor with the mouse', () => {
it('double-click opens the editor with the current value and focus on the select', async () => {
const { container } = renderGrid();
const select = await openByDoubleClick(container);
expect(select.value).toBe('Editor');
expect(select.getAttribute('aria-label')).toBe('Role');
expect(cellOf(container, 0, 'role')!.className).toContain('ogx__cell--editing');
});

// Same concern as the 3.2.1 boolean fix: if the engine does not focus a <select> on mousedown,
// the focusable cell around it would take focus, the blur would commit and the editor unmount.
it('clicking the open select keeps the editor open, focused and uncommitted', async () => {
const { container, processRowUpdate } = renderGrid();
const select = await openByDoubleClick(container);
await userEvent.click(select);
await frame();
expect(editorOf(container)).toBe(select);
expect(document.activeElement).toBe(select);
expect(processRowUpdate).not.toHaveBeenCalled();
// Close a popup the click may have opened, so the rest of the page gets the input again.
await userEvent.keyboard('{Escape}');
});

it('picking an option does not commit or close the editor by itself', async () => {
const { container, processRowUpdate } = renderGrid();
const select = await openByDoubleClick(container);
await userEvent.selectOptions(select, 'Viewer');
await frame();
expect(editorOf(container)).toBe(select);
expect(select.value).toBe('Viewer');
expect(document.activeElement).toBe(select);
expect(processRowUpdate).not.toHaveBeenCalled();
});

it('clicking another cell commits the picked option and the cell shows it', async () => {
const { container, processRowUpdate } = renderGrid();
const select = await openByDoubleClick(container);
await userEvent.selectOptions(select, 'Viewer');
await userEvent.click(cellOf(container, 1, 'name')!);
await expect.poll(() => editorOf(container)).toBeNull();
await expect.poll(() => processRowUpdate.mock.calls.length).toBe(1);
expect(processRowUpdate.mock.calls[0][0]).toMatchObject({ id: 1, role: 'Viewer' });
await expect.poll(() => cellOf(container, 0, 'role')!.textContent).toBe('Viewer');
expect(cellOf(container, 1, 'name')!.className).toContain('ogx__cell--focused');
});

it('click, pick, then Enter commits and returns focus to the cell', async () => {
const { container, processRowUpdate } = renderGrid();
const select = await openByDoubleClick(container);
await userEvent.click(select);
// Escape on an open popup closes the popup only; on a closed select it cancels the edit
// (headless engines here: always cancels). Reopen the editor in that case.
await userEvent.keyboard('{Escape}');
const active = editorOf(container) ?? await openByDoubleClick(container);
await userEvent.selectOptions(active, 'Admin');
active.focus();
await userEvent.keyboard('{Enter}');
await expect.poll(() => editorOf(container)).toBeNull();
expect(processRowUpdate).toHaveBeenCalledTimes(1);
expect(processRowUpdate.mock.calls[0][0]).toMatchObject({ id: 1, role: 'Admin' });
await expect.poll(() => cellOf(container, 0, 'role')!.textContent).toBe('Admin');
await expect.poll(() => document.activeElement).toBe(cellOf(container, 0, 'role'));
});
});

describe('singleSelect editor with the keyboard', () => {
it('Enter on the focused cell opens the editor with focus on the select', async () => {
const { container } = renderGrid();
const select = await openByEnter(container);
expect(select.value).toBe('Editor');
});

// Not implemented: only Enter and double-click open an editor (handleGridKeyDown in
// hooks/core/gridKeyDown.ts has no F2 or printable-key branch). `it.fails` flags it once it is.
it.fails('F2 on the focused cell opens the editor', async () => {
const { container } = renderGrid();
await expect.poll(() => cellOf(container, 0, 'role')).not.toBeNull();
await userEvent.click(cellOf(container, 0, 'role')!);
await userEvent.keyboard('{F2}');
await frame();
expect(editorOf(container)).not.toBeNull();
});

it.fails('typing a character on the focused cell opens the editor', async () => {
const { container } = renderGrid();
await expect.poll(() => cellOf(container, 0, 'role')).not.toBeNull();
await userEvent.click(cellOf(container, 0, 'role')!);
await userEvent.keyboard('v');
await frame();
expect(editorOf(container)).not.toBeNull();
});

it('ArrowDown / ArrowUp stay in the select: the grid neither moves focus nor ends the edit', async () => {
const { container, processRowUpdate } = renderGrid();
const select = await openByEnter(container);
const seen: string[] = [];
await userEvent.keyboard('{ArrowDown}');
await frame();
seen.push(select.value);
expect(editorOf(container)).toBe(select);
expect(document.activeElement).toBe(select);
await userEvent.keyboard('{ArrowUp}');
await frame();
seen.push(select.value);
expect(editorOf(container)).toBe(select);
expect(document.activeElement).toBe(select);
// The focused cell is still the edited one, not the row below or above.
expect(cellOf(container, 1, 'role')!.className).not.toContain('ogx__cell--focused');
expect(processRowUpdate).not.toHaveBeenCalled();
// On a closed select ArrowDown picks the next option (Editor → Viewer) and ArrowUp goes back;
// where the engine opens a popup instead (macOS: all three engines here), the value stays put.
expect(['Viewer,Editor', 'Editor,Editor']).toContain(seen.join(','));
await userEvent.keyboard('{Escape}');
});

it('ArrowLeft / ArrowRight do not move grid focus while editing', async () => {
const { container } = renderGrid();
const select = await openByEnter(container);
await userEvent.keyboard('{ArrowRight}');
await userEvent.keyboard('{ArrowLeft}');
await frame();
expect(editorOf(container)).toBe(select);
expect(document.activeElement).toBe(select);
expect(cellOf(container, 0, 'team')!.className).not.toContain('ogx__cell--focused');
expect(cellOf(container, 0, 'name')!.className).not.toContain('ogx__cell--focused');
await userEvent.keyboard('{Escape}');
});

it('typing an option\'s first letter on the focused select picks it (type-ahead), Enter commits', async () => {
const { container, processRowUpdate } = renderGrid();
const select = await openByEnter(container);
await userEvent.keyboard('v');
await expect.poll(() => select.value).toBe('Viewer');
expect(editorOf(container)).toBe(select);
await userEvent.keyboard('{Enter}');
await expect.poll(() => editorOf(container)).toBeNull();
expect(processRowUpdate).toHaveBeenCalledTimes(1);
expect(processRowUpdate.mock.calls[0][0]).toMatchObject({ id: 1, role: 'Viewer' });
await expect.poll(() => cellOf(container, 0, 'role')!.textContent).toBe('Viewer');
await expect.poll(() => document.activeElement).toBe(cellOf(container, 0, 'role'));
});

it('a value moved to with the arrows is committed by Enter', async () => {
const { container, processRowUpdate } = renderGrid();
const select = await openByEnter(container);
await userEvent.keyboard('{ArrowDown}');
await frame();
const picked = select.value;
await userEvent.keyboard('{Enter}');
await expect.poll(() => editorOf(container)).toBeNull();
if (picked === 'Editor') {
// The engine opened a popup on ArrowDown instead of changing the value: nothing changed.
expect(processRowUpdate).not.toHaveBeenCalled();
} else {
expect(processRowUpdate).toHaveBeenCalledTimes(1);
expect(processRowUpdate.mock.calls[0][0]).toMatchObject({ id: 1, role: picked });
}
await expect.poll(() => cellOf(container, 0, 'role')!.textContent).toBe(picked);
});

it('Escape cancels and restores the old value', async () => {
const { container, processRowUpdate } = renderGrid();
const select = await openByEnter(container);
await userEvent.selectOptions(select, 'Admin');
select.focus();
await userEvent.keyboard('{Escape}');
await expect.poll(() => editorOf(container)).toBeNull();
expect(cellOf(container, 0, 'role')!.textContent).toBe('Editor');
await frame();
expect(processRowUpdate).not.toHaveBeenCalled();
await expect.poll(() => document.activeElement).toBe(cellOf(container, 0, 'role'));
// Reopening shows the stored value, not the abandoned pick.
await userEvent.keyboard('{Enter}');
await expect.poll(() => editorOf(container)?.value).toBe('Editor');
});

it('Tab commits once and moves focus to the next editable cell', async () => {
const { container, processRowUpdate } = renderGrid();
const select = await openByEnter(container);
await userEvent.selectOptions(select, 'Admin');
select.focus();
await userEvent.keyboard('{Tab}');
await expect.poll(() => editorOf(container)).toBeNull();
await frame();
expect(processRowUpdate).toHaveBeenCalledTimes(1);
expect(processRowUpdate.mock.calls[0][0]).toMatchObject({ id: 1, role: 'Admin' });
expect(cellOf(container, 0, 'role')!.textContent).toBe('Admin');
expect(cellOf(container, 0, 'team')!.className).toContain('ogx__cell--focused');
await expect.poll(() => document.activeElement).toBe(cellOf(container, 0, 'team'));
});

it('Shift+Tab commits once and moves focus to the previous editable cell', async () => {
const { container, processRowUpdate } = renderGrid();
const select = await openByEnter(container);
await userEvent.selectOptions(select, 'Viewer');
select.focus();
await userEvent.keyboard('{Shift>}{Tab}{/Shift}');
await expect.poll(() => editorOf(container)).toBeNull();
await frame();
expect(processRowUpdate).toHaveBeenCalledTimes(1);
expect(processRowUpdate.mock.calls[0][0]).toMatchObject({ id: 1, role: 'Viewer' });
await expect.poll(() => document.activeElement).toBe(cellOf(container, 0, 'name'));
});

it('Space and Alt+ArrowDown on the focused select leave the edit open (they open the popup natively)', async () => {
const { container, processRowUpdate } = renderGrid();
const select = await openByEnter(container);
await userEvent.keyboard(' ');
await frame();
expect(editorOf(container)).toBe(select);
await userEvent.keyboard('{Escape}');
const active = editorOf(container) ?? await openByEnter(container);
await userEvent.keyboard('{Alt>}{ArrowDown}{/Alt}');
await frame();
expect(editorOf(container)).toBe(active);
expect(processRowUpdate).not.toHaveBeenCalled();
expect(cellOf(container, 1, 'role')!.className).not.toContain('ogx__cell--focused');
await userEvent.keyboard('{Escape}');
});
});
Loading
Loading