Skip to content

Commit ea973a7

Browse files
committed
feat(locale): keep an inline localeText object stable across renders
useShallowStableValue keeps the previous localeText while a new one holds the same entries, so the resolved locale and the rows reading it do not update on every parent render; docs and CHANGELOG
1 parent f374db4 commit ea973a7

7 files changed

Lines changed: 98 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
- **Nine theme keys** (additive; each defaults to exactly what the grid draws today, checked by the default-theme baseline in Chromium, Firefox and WebKit): `grid.checkboxCheckedBackground` (`--ogx-checkbox-checked-bg`, the fill and border of a checked or indeterminate checkbox, default the primary colour), `grid.checkboxCheckmark` (`--ogx-checkbox-checkmark`, default white), `grid.expandIconColor` / `expandIconHoverColor` (`--ogx-expand-icon-color` / `-hover-color`, the group and tree chevron, default header text / row text), and for menus `overlays.itemIndicatorColor` (`--ogx-overlay-item-indicator`, the selection dot, default the selected item text), `itemSelectedIcon` (`--ogx-overlay-item-selected-icon`, default primary), `iconColor` (`--ogx-overlay-icon`, default gray-400), `sectionLabelText` (`--ogx-overlay-section-label-text`, default gray-400) and `dividerColor` (`--ogx-overlay-divider`, default the overlay border).
1313
- **Theme Builder controls** for those keys and for existing keys it did not offer: a *Checkboxes* group (background, border, checked fill, check mark), a *Group and tree rows* group (chevron, chevron hover), *Selected row hover*, a *Toolbar and search box* group (toolbar text, search box background, text, border, focus border), and in *Column menu and panels* the item hover text, selected item text, selection dot, selected item icon, icons, section labels, dividers and danger item colours.
1414

15+
- **`localeText` covers every built-in UI string** (196 optional keys, additive; without them the grid shows the same English as before): the column menu, toolbar and quick search, filter panel (including operator names), columns panel, pivot panel, AI assistant panel and its status line, pagination buttons, row and header checkboxes and handles, cell selection statistics, list view, loading and error overlays, the error cell and screen-reader announcements. The strings are resolved once per grid and shared through a context, so rows read them without a new prop. Components used outside a grid (`GridToolbar`, `ColumnVisibilityPanel`, `FilterPanel`) show English; their explicit props still win. Not covered: the AI assistant's change-chip texts, the grouping column's default header (`groupingColDef.headerName` sets it), and row data or file contents such as the pivot "Grand Total" row and export subtotals. An inline `localeText` object of strings keeps the rows memoised; function entries are compared by identity. See *Locale text for every UI string* in `docs/API_REFERENCE.md`; the Locale Text demo shows a French grid.
16+
1517
### Demo
1618

1719
- **Sidebar reorganised by what developers look for first**: *Getting started* (with Basic Usage and the API reference), *Sorting & filtering*, *Editing*, *Pagination & server data*, *Export & clipboard*, *Grouping & aggregation*, *Selection & spreadsheet*, *Layout*, *Theming & customization*, *AI*, *Advanced*, *Tutorials*, then *Resources* (licensing, support, migration guides). Page URLs are unchanged. Text pages are marked `doc` (full width) instead of relying on their category.

‎docs/API_REFERENCE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1021,7 +1021,7 @@ Since v3.9 every built-in string of the grid goes through `localeText`: the tool
10211021
- The strings reach portalled menus and panels through React context, so a custom toolbar slot that renders the built-in `GridToolbar` is translated too.
10221022
- Components used outside a grid (`ColumnVisibilityPanel`, `GridToolbar`, `FilterPanel`, `Pagination`, `QuickFilter`) show English. Their own props (`placeholder`, `aiAssistantLabel`, `Pagination`'s `localeText`) still win.
10231023
- Not covered: the grouping column's default header (`'Group'`, set `groupingColDef.headerName`), pivot's `'Grand Total'` row label, export file contents, the AI change chips' labels, and developer console warnings.
1024-
- Pass a stable object (a module constant or `useMemo`): the grid resolves the strings once per `localeText` object.
1024+
- An inline object of strings is fine: while it holds the same entries as on the previous render (records such as `filterPanelOperators` compared one level deep), the grid keeps the resolved strings and the rows are not re-rendered. Functions are compared by identity, so define function entries (`rowSelectLabel: id => …`) outside the render, or keep the whole object in a module constant or `useMemo`.
10251025

10261026
### `GridRowMeta`
10271027

‎docs/superpowers/plans/2026-10-13-audit-tracker.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil
4747
| R4 | Reduce ~41 `as unknown as` casts (internal column type) | L | ⬜ | |
4848
| R5 | Split files >600 lines (`GridToolbar.tsx` 855, …) | M each | ⬜ | |
4949
| R6 | Narrow wide hook interfaces (keyboard nav: 31 params) | L | ⬜ | |
50-
| R7 | Route ~70 hard-coded English strings through `localeText` | M | ⬜ | new public keys → minor release |
50+
| R7 | Route ~70 hard-coded English strings through `localeText` | M | ✅ | 196 keys (more than the ~70 estimated: pivot/toolbar/AI status included), `GridLocaleContext`, `useShallowStableValue`. Not covered: AI chip texts, grouping column default header, file/row contents |
5151
| R8 | Document 24 exports missing from `docs/API_REFERENCE.md` | S | ✅ | All 23 missing exports documented in `API_REFERENCE.md` |
5252

5353
## Log
@@ -68,3 +68,4 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil
6868
- 2026-10-13 — Branch `release/3.9.0`: 9 theme keys + Theme Builder controls (user request), sidebar reorganised by developer priority (user request), demo version label from package.json.
6969
- 2026-10-13 — Flaky tests fixed (reorder, header-filter scroll, PDF heartbeat, tree-data ratio). R7 (localeText for ~120 strings) delegated to an agent in a worktree; P8 waits for it (same files).
7070
- 2026-10-13 — P8 done (row memoisation), regression test proven to fail without the fixes.
71+
- 2026-10-13 — R7 done (agent, reviewed): 196 localeText keys via GridLocaleContext; added useShallowStableValue so an inline localeText of strings keeps rows memoised. Unit 1972, browser 585/585 in 3 engines.

‎lib/components/DataGrid/DataGrid.rowMemo.test.tsx‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, afterEach } from 'vitest';
22
import { render, fireEvent, act, cleanup } from '@testing-library/react';
3+
import { useState } from 'react';
34
import { DataGrid } from './DataGrid';
45
import type { GridColDef } from '../../types';
56

@@ -41,4 +42,29 @@ describe('row memoisation', () => {
4142
expect(renders.get(0) ?? 0).toBe(0);
4243
expect(renders.get(3) ?? 0).toBe(0);
4344
});
45+
46+
it('an inline localeText object of plain strings keeps rows memoised', () => {
47+
const renders = new Map<number, number>();
48+
const isCellEditable = ({ row }: { row: Row }) => {
49+
renders.set(row.id, (renders.get(row.id) ?? 0) + 1);
50+
return true;
51+
};
52+
const columns: GridColDef<Row>[] = [{ field: 'name', editable: true }];
53+
let bump = () => {};
54+
function Parent() {
55+
const [n, setN] = useState(0);
56+
bump = () => setN(n + 1);
57+
return (
58+
<div data-n={n}>
59+
<DataGrid<Row> rows={ROWS} columns={columns} height={400} isCellEditable={isCellEditable}
60+
localeText={{ noRowsLabel: 'Keine Daten', columnMenuUnsort: 'Unsortiert' }} />
61+
</div>
62+
);
63+
}
64+
render(<Parent />);
65+
renders.clear();
66+
act(() => { bump(); });
67+
expect(renders.get(0) ?? 0).toBe(0);
68+
expect(renders.get(4) ?? 0).toBe(0);
69+
});
4470
});

‎lib/components/DataGrid/GridRoot.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useMemo } from 'react';
22
import { GridLocaleContext } from './gridLocaleContext';
33
import { resolveGridLocale } from '../../utils/gridLocale';
4+
import { useShallowStableValue } from '../../hooks/core/useShallowStableValue';
45
import { GridToolbarHostContext } from '../../hooks/core/gridToolbarHostContext';
56
import { GridToolbarSlot } from './GridToolbarSlot';
67
import { GridAiAssistantArea } from '../AiAssistant/GridAiAssistantArea';
@@ -97,7 +98,8 @@ export function GridRoot<R extends GridRowModel>(props: GridRootProps<R>) {
9798
// body shows skeletons or the loading overlay instead, and infinite scroll appends skeleton rows.
9899
const showLoadingOverRows = effectiveLoading && allRenderableRows.length > 0 && paginationMode !== 'infinite';
99100
const ToolbarSlot = slots?.toolbar;
100-
const locale = useMemo(() => resolveGridLocale(localeText), [localeText]);
101+
const stableLocaleText = useShallowStableValue(localeText);
102+
const locale = useMemo(() => resolveGridLocale(stableLocaleText), [stableLocaleText]);
101103

102104
return (
103105
<GridLocaleContext.Provider value={locale}>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { renderHook } from '@testing-library/react';
3+
import { useShallowStableValue } from './useShallowStableValue';
4+
5+
describe('useShallowStableValue', () => {
6+
it('keeps the first object while new ones hold the same entries', () => {
7+
const fn = () => 'x';
8+
const first = { a: 'A', ops: { contains: 'enthält' }, f: fn };
9+
const { result, rerender } = renderHook(({ v }) => useShallowStableValue(v), { initialProps: { v: first } });
10+
rerender({ v: { a: 'A', ops: { contains: 'enthält' }, f: fn } });
11+
expect(result.current).toBe(first);
12+
});
13+
14+
it('takes a new object when an entry changes, including a new function', () => {
15+
const first = { a: 'A', f: () => 'x' };
16+
const { result, rerender } = renderHook(({ v }) => useShallowStableValue(v), { initialProps: { v: first } });
17+
const changed = { a: 'B', f: first.f };
18+
rerender({ v: changed });
19+
expect(result.current).toBe(changed);
20+
const newFn = { a: 'B', f: () => 'x' };
21+
rerender({ v: newFn });
22+
expect(result.current).toBe(newFn);
23+
});
24+
25+
it('handles undefined and swaps between object and undefined', () => {
26+
const initialProps: { v: { a: string } | undefined } = { v: undefined };
27+
const { result, rerender } = renderHook(({ v }) => useShallowStableValue(v), { initialProps });
28+
expect(result.current).toBeUndefined();
29+
const obj = { a: 'x' };
30+
rerender({ v: obj });
31+
expect(result.current).toBe(obj);
32+
rerender({ v: undefined });
33+
expect(result.current).toBeUndefined();
34+
});
35+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useState } from 'react';
2+
3+
/** Equal when both hold the same keys with `Object.is`-equal values, one level of plain records deep. */
4+
function isShallowEqual(a: unknown, b: unknown, depth = 1): boolean {
5+
if (Object.is(a, b)) return true;
6+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) || Array.isArray(b)) return false;
7+
const ra = a as Record<string, unknown>;
8+
const rb = b as Record<string, unknown>;
9+
const keys = Object.keys(ra);
10+
if (keys.length !== Object.keys(rb).length) return false;
11+
return keys.every(key => Object.prototype.hasOwnProperty.call(rb, key)
12+
&& (Object.is(ra[key], rb[key]) || (depth > 0 && isShallowEqual(ra[key], rb[key], depth - 1))));
13+
}
14+
15+
/**
16+
* `value`, or the previous one while the new one holds the same entries. A consumer passing an
17+
* inline object (`localeText={{ … }}`) then keeps one identity across renders, so what is memoised
18+
* on it (the resolved locale, and every row that reads it) does not update needlessly. Functions
19+
* inside are compared by identity: an inline function still counts as a change.
20+
*/
21+
export function useShallowStableValue<T>(value: T): T {
22+
const [stable, setStable] = useState(value);
23+
if (stable !== value && !isShallowEqual(stable, value)) {
24+
// Adjusting state while rendering (React's documented pattern for derived state).
25+
setStable(value);
26+
return value;
27+
}
28+
return stable;
29+
}

0 commit comments

Comments
 (0)