Skip to content

[DataGridPremium] Formula support - #22807

Merged
JCQuintas merged 49 commits into
mui:masterfrom
MBilalShafi:feat/formula-support
Aug 21, 2026
Merged

JCQuintas merged 49 commits into
mui:masterfrom
MBilalShafi:feat/formula-support

Conversation

@MBilalShafi

@MBilalShafi MBilalShafi commented Jun 15, 2026 •

Copy link
Copy Markdown
Member

Preview

Summary

Adds Excel-like formula support to the Data Grid Premium. In columns opted in via colDef.allowFormulas, a cell whose row-data value starts with = is parsed and evaluated by a new built-in formula engine. The evaluated value flows through rendering, sorting, filtering, quick filter, aggregation, row grouping, row spanning, and export, while the formula source remains the canonical value stored in row data.

The engine is internal and independent to any of the Data Grid's interfaces with the defined grammer explained below.

Architecture

Two strictly separated layers:

  1. Pure engine — hooks/features/formula/engine/: tokenizer, recursive-descent parser (Excel operator precedence, including the -2^2 = 4 / 2^3^2 = 64 quirks), serializer, static dependency extraction, function registry (23 built-ins + user extensible), evaluator, and a dependency graph with Kahn topological recompute and cycle detection. No React, no grid imports — it defines its own structural types and is fully unit-testable in isolation.
  2. Grid adapter — hooks/features/formula/*: state/cache/selectors, incremental evaluation passes, the value overlay, editing pipeline, and the export/fill integrations. Mirrors the aggregation feature's file layout.

Design decisions

  • The formula source lives only in row data; everything else is derived. This is the load-bearing invariant: undo/redo, processRowUpdate, and controlled-rows scenarios work with zero extra code, because history replays raw row values and evaluation re-derives results.
  • The engine is not exported from the public barrel (internal seam, keeps a future pluggable-engine escape hatch open) and deliberately not placed in @mui/x-internals (that package is MIT — publishing the engine there would let anyone build formula support on the MIT grid).
  • References are stored in a canonical dialect (REF(COLUMN("field"), ROW("id")) for identity-stable refs, REF(COLUMN_POSITION(i), ROW_POSITION(j)) for position-fixed refs, plus RANGE(...), COLUMN_VALUES("field"), and bare field names for same-row refs). A1 notation is a display-only editor layer (see below). The stable-vs-positional split is exactly Excel's relative-vs-absolute distinction, which is what later powers fill-handle adjustment and Excel export for free.
  • Membership-based value overlay (aggregation → formula → community) in getCellValue/getRowValue/getRowFormattedValue: the branch checks lookup-entry presence, not value truthiness, so a formula evaluating to null can never leak the raw = source into sorting/filter/export. Error results bypass valueFormatter and render their error code.
  • Incremental evaluation: rows are diffed by object reference, range dependencies are stored as interval records (never exploded into per-cell edges), recompute runs over the dirty closure only, and each pass commits one setState with copy-on-write lookup records so only changed cells re-render. Parsed ASTs are interned by source string (a 100k-row computed column parses its formula once). Measured on the evaluation layer: 100k rows × 1 formula column ≈ 390 ms full pass / ≈ 10 ms single-edit diff; SUM(COLUMN_VALUES(...)) over 100k rows ≈ 71 ms full / ≈ 45 ms incremental.

What's included

Formula engine

  • Grammar: numbers, strings ("" escape), TRUE/FALSE, comparison/&/+ -/* //^/unary with Excel precedence; bare identifiers are same-row field refs, FIELD("unit price") escapes arbitrary field names; '=text (apostrophe) stores a literal string.
  • 23 built-in functions (SUM, AVERAGE, MIN, MAX, COUNT, COUNTA, ROUND, ABS, MOD, POWER, IF, AND, OR, NOT, IFERROR, ISBLANK, CONCAT/CONCATENATE, LEN, UPPER, LOWER, TRIM, LEFT, RIGHT), with lazy args (IF/AND/OR never evaluate untaken branches) and error-accepting functions (IFERROR, ISBLANK).
  • Error taxonomy: #REF!, #DIV/0!, #CYCLE!, #NAME?, #VALUE!, #ERROR! with strict first-error propagation. #CYCLE! is assigned only by the graph layer (single-pass Kahn over the affected subgraph; iterative everywhere, so 10k-deep chains don't blow the stack).
  • Permissive commit: a syntactically invalid formula still commits and renders its error code — it never blocks editing.
  • Hostile-input hardening: recursion-depth and AST-height bounds so parseFormula/evaluateFormula honor a never-throws contract.

Cross-row references, ranges, position context

  • Stable refs track row/column identity (survive re-sorting); positional refs and ranges resolve against the position context = sorted + filtered visible order (1-based, autogenerated/pinned rows and utility columns excluded, pagination ignored).
  • Single-pass rebind policy (no fixpoint): sorting/filtering consume formula values as of when the comparator ran; after sortedRowsSet/filteredRowsSet the position-dependent formulas rebind and re-evaluate exactly once; the grid never auto re-sorts in response. Deterministic and loop-free (a positional ref feeding a sorted column would otherwise oscillate), and Excel-consistent — Excel never auto-resorts after recalc either.
  • Removing a referenced row/column produces #REF! in dependents only; re-adding it recovers.
  • Row grouping and row spanning are re-applied on evaluated values after formula passes.

Editing

  • The formula editor takes over when the cell holds a formula or the user starts an edit by typing =; a column's custom renderEditCell always wins over it. Non-formula cells keep their original editor.
  • The editor seeds the source (not the evaluated value), and a valueSetter equality guard protects formulas on commit paths that bypass the editor (row edit mode, programmatic commits) — the two data-loss traps of naive implementations.
  • Single-layer contenteditable editor (GridFormulaEditor) rendering each reference as a colored inline <span>, with caret save/restore across rebuilds, IME-composition guarding, plain-text paste, and single-line enforcement. This replaced an earlier transparent-<input> + colored-mirror approach that could not be made pixel-perfect (letter-spacing drift, invisible native selection on iOS, sub-pixel vertical offset) — with one layer, caret/selection/alignment are native by construction.
  • Reference highlighting: the colored editor tokens are matched by dashed rectangles over the referenced grid cells (Google Sheets style). The overlay portals into the virtual scroller and positions in content space, so rectangles scroll natively with cells — no scroll listeners.
  • Autocomplete (on by default, disableFormulaAutocomplete opt-out): ranked functions/operators/special forms/field names (+ column letters in A1 mode), function signature help, caret-aware token splicing. Custom functions appear with their optional signature/description/category metadata. Opt-out exists because the open popup consumes Enter/Tab/Arrows instead of committing/navigating.

Formula bar

  • A spreadsheet-style formula bar for the focused cell — enabled on the default toolbar via slotProps.toolbar.formulaBar, or composed anywhere (including portals) through the exported FormulaBar component. Component docs + API pages included.
  • The bar is a permanently open formula editor, not an auto-started cell edit. In view mode, typing edits a draft: references are colored in the bar and outlined in the grid, and the draft's result is previewed live through a read-only evaluation — nothing commits while typing. Enter commits and moves down, Tab commits and moves right, clicking another cell or leaving the bar commits, Escape is the only discard. Commits run through the clipboard-paste pipeline (valueSetter, editability checks, processRowUpdate, single undo step).
  • With the cell in edit mode, the bar is a two-way live mirror of the cell editor; interacting with the bar never closes the cell's editor (a canUpdateFocus veto protects the edit while the bar owns focus).

A1 notation (opt-in formulaA1Notation, default off)

  • Editing-UI-only dialect: the editor displays/accepts A1, $A$1, A1:B5, A:A; commit freezes to canonical. Row data, copy, export, and getCellFormula always carry canonical — A1 is never stored.
  • Mapping: bare A1 → stable ref (relative), $-anchored axis → positional ref (absolute), per axis ($A1/A$1 mix).
  • When enabled: column headers gain letter adornments and a leftmost pinned row-number utility column shows 1-based view positions (sequential top-to-bottom; rows move between numbers on re-sort, like a spreadsheet). Both match exactly what ROW_POSITION/A1 numbers resolve to.
  • Same-row refs stay bare field names (=price * quantity) even in A1 mode — an A1 cell address would freeze them to a specific row.

Fill handle + keyboard fill

  • Dragging the cell-selection fill handle (and Ctrl+D / Ctrl+R) from a formula cell copies the formula with Excel-style relative reference adjustment: stable (relative) refs shift by the 2-D (rowDelta, columnDelta); positional ($) refs stay fixed; range endpoints shift independently (so =SUM($A$1:A1) running totals work); overshoot bakes #REF!.
  • Always-on and built-in (no new prop): filling a formula as its frozen evaluated value is strictly worse.
  • Per-target eligibility: a target column without allowFormulas receives the source's evaluated value instead of an inert = string; one gesture across mixed columns does the right thing per cell.

Excel export

  • With escapeFormulas: false, live formula cells export as real Excel formulas (cell.value = { formula, result }) whose A1 refs are re-anchored to the exported sheet's own layout (header/group-header offset, export column order/subset — which can differ from the grid's visible order). The cached result means values display without recalc.
  • Gated on the existing escapeFormulas option (no new prop): the default (true) keeps today's value-only, injection-escaped output byte-identical; false already meant "allow = content to be live in Excel". $ fidelity mirrors the grid (stable → relative, positional → absolute). References to rows/columns not in the export bake #REF! honestly. Works in both the sync and web-worker export paths.
  • CSV, clipboard copy, and print continue to surface evaluated values.

Public API surface

Surface Addition
GridColDef allowFormulas?: boolean (default false — opt-in per column, since implicit enablement would silently reinterpret existing =-prefixed data)
Props disableFormulas (default false), formulaFunctions (custom function registry, replacement semantics like aggregationFunctions), formulaA1Notation (default false), disableFormulaAutocomplete (default false)
Components FormulaBar (+ FormulaBarProps), wired to the default toolbar via slotProps.toolbar.formulaBar
Events formulaEvaluated: { changedCells: GridCellCoordinates[] }
Selectors gridFormulaStateSelector, gridFormulaLookupSelector, gridCellFormulaResultSelector (+ the GridFormulaResult/GridFormulaState/function-definition types and the GRID_FORMULA_FUNCTIONS built-in registry)
Locale text formulaBarLabel, formulaBarInputLabel, formulaBarAddressLabel
  • The imperative formula API (setCellFormula, getCellFormula, getCellFormulaResult, validateCellFormula, reevaluateFormulas) is private for now: reading results is covered by the public selectors and writing by updateRows(). It can be promoted later if a userland use case appears.
  • No unstable_ prefixes — the feature is opt-in per column. Formula errors surface as locale-neutral codes (#REF!, …), so the three formula-bar labels above are the only locale additions.

Community/Pro footprint

Per the CI bundle-size report: @mui/x-data-grid +245 B parsed / +69 B gzip, @mui/x-data-grid-pro +250 B / +79 B, @mui/x-data-grid-premium +86.8 KB / +25.6 KB.

The community/pro source changes are 16 files, +111 lines — all opt-in seams that are inert unless the premium formula hooks register into them: a column-header adornment configuration hook (A1 letters), a canUpdateFocus pipe consult in GridCell's focus-steal effect (formula bar / floating editor focus survival), the private resetRowSpanningState API (row-spanning re-application after evaluation passes), a formulaBar pass-through on the toolbars, CSS classes, the three locale keys, and a focusElement internals export.

Scope

Implemented in this PR

  • Core formula engine (parser/evaluator/dependency graph, 23 built-ins, error taxonomy)
  • Grid adapter: value overlay, incremental evaluation, editing pipeline, GridFormulaApi
  • Cross-row refs, RANGE/COLUMN_VALUES, position context + single-pass rebind
  • Row grouping / row spanning / aggregation over evaluated values
  • Opt-in A1 notation layer (header letters, row-number column, A1 ⇄ canonical)
  • Formula editor autocomplete with signature help
  • Reference highlighting (colored tokens + cell outlines) and the contenteditable editor
  • Custom renderEditCell takes precedence over the formula editor
  • Fill handle + Ctrl+D/Ctrl+R reference adjustment
  • Excel live-formula export (opt-in via escapeFormulas: false); CSV/clipboard/print export evaluated values
  • Custom functions via formulaFunctions
  • Floating editor that grows with the formula — per review feedback (static first-paint geometry; popper prototypes discarded for jitter)
  • Edit session survives scrolling/virtualization remounts and live column drag-resize
  • Built-in formula bar (FormulaBar + slotProps.toolbar.formulaBar) with draft preview and edit-mode mirroring
  • Docs restructure: Overview / Formula syntax / Formula engine pages + Formula Bar component page

Remaining before merge (in this PR)

  • Compatibility sweep with other grid features (row reordering, master-detail, pinned rows/columns edge cases, RTL) + minor adjustments

Follow-ups (out of scope for v1, separate PRs)

  • Clipboard copy/paste preserves formulas with reference adjustment (v1 copies evaluated values; the fill-handle offset primitive is reusable for this)
  • Editor wrap-and-grow-down when horizontal growth hits the viewport edge (v1 scrolls internally; the static-surface design makes this a mostly-CSS follow-up)
  • Volatile built-ins (NOW/TODAY/RAND) — the volatile registry flag and its semantics already exist for custom functions
  • Expanded built-in function library (lookups, conditional aggregates: VLOOKUP, SUMIF, COUNTIF, …)
  • Locale-aware formula syntax (argument separators, decimal comma) and localized function metadata
  • In-editor undo/redo (row-level undo already works via the storage model)
  • Reference-highlight rectangles over pinned columns (currently not drawn there)
  • Public fill-operation customization callback
  • Formula support with the server-side data source and pivoting
  • [data grid] Support computed formula columns #23519

Known limitations (documented on the docs page / in code docs)

  • Not supported with dataSource or while pivoting is active (evaluation is disabled with a dev warning).
  • Formula syntax is en-US only (, argument separator, . decimal).
  • Pinned rows are excluded from the position context (a formula can't positionally reference them).
  • The formula editor is single-line and has no in-editor undo due to contenteditable rather than <input /> — grid-level undo/redo of committed values works as usual.
  • A formula column's own valueGetter is ignored for its formula cells (dev warning); it applies normally to plain cells.
  • Clipboard copy places evaluated values on the clipboard — the fill handle is the in-grid way to replicate formulas.
  • Excel export: COLUMN_VALUES/RANGE emit one contiguous A1 range, so a manual Excel recalc can diverge from the cached (correct) result when the export interleaves group rows; non-Excel custom functions show their cached value but error on manual recalc.
  • escapeFormulas is a single switch for both injection escaping and formula export.

Notes for reviewers

  • The feature landed as independently green commits, so commit-by-commit review works:

    Commit Contents
    b438be388e Pure formula engine
    7a8e8fd385 Grid adapter (overlay, incremental passes, editing pipeline)
    9a6351abee Ranges, position context, re-group/re-span
    3341eb6dbf A1 notation layer
    978f62f8b6 Editor autocomplete
    f32abf07d9 Fill handle + Ctrl+D/Ctrl+R reference adjustment
    a4ad0f52f2 Excel live-formula export
    6f71a3a89c Custom renderEditCell precedence
    227d2baa92 Reference highlighting
    f156094b97 Single-layer contenteditable editor
    ae71d71d29 Formula API made private
    3fd2cb22f7 Docs overhaul
    0ad689b45b Edit session survives scrolling/virtualization
    60f4f247e1 Floating grow-with-content editor surface (+ 2dfe6b050a)
    9bcaa60f38 Live column-resize sync for highlights + editor surface
    842bdd72f4 Review feedback: event renamed to formulaEvaluated, doc fixes
    52e90230e6 Formula bar + docs restructure

    (plus small fix-up commits and master merges)

  • Suggested reading order: engine/index.ts (public engine surface) → createFormulaEvaluation.ts + useGridFormula.ts (pass pipeline) → the overlay in useGridParamsOverridableMethods.ts → wrapColumnWithFormula.tsx + GridFormulaEditCell.tsx (commit path + routing) → GridFormulaEditable.tsx + GridFormulaEditor.tsx (editor core + floating surface) → formulaBar/FormulaBar.tsx → gridFormulaFill.ts / gridFormulaExcelExport.ts (integrations).

  • Review feedback addressed so far: the grow-with-the-value editor ask (review 4503329712) shipped in 60f4f247e1/9bcaa60f38 — after two popper-based prototypes were rejected for live jitter, the surface derives its geometry statically from grid state (post-mortem in D24); the event rename and API-surface trims from the earlier review round landed in 842bdd72f4/ae71d71d29.

MBilalShafi and others added 3 commits June 10, 2026 22:11
First iteration of the Excel-like formula feature: a standalone engine with
zero React/grid imports under hooks/features/formula/engine/.

- Tokenizer, recursive-descent parser (Excel operator precedence, special
  forms REF/COLUMN/ROW/COLUMN_POSITION/ROW_POSITION/FIELD/RANGE/COLUMN_VALUES
  with literal-only arguments), and canonical serializer with a round-trip
  guarantee
- Dependency extraction (static) and position-context binding with
  interval-record range dependencies (never exploded per-cell edges)
- Evaluator with spreadsheet error taxonomy (#REF!, #DIV/0!, #CYCLE!, #NAME?,
  #VALUE!, #ERROR!), strict left-to-right error propagation, lazy IF/AND/OR,
  IFERROR/ISBLANK error observers, and a never-throws contract backed by
  parser depth/height bounds
- Function registry with replacement semantics and 23 built-ins (+
  CONCATENATE alias); reserved and unreachable names rejected
- Kahn-based recompute ordering with built-in cycle detection (iterative,
  stack-safe)
- Ranges and positional references parse but evaluate to #REF! until the
  position-context iteration; AST and resolver types are now frozen
- Design doc with the locked decisions in docsTech/data-grid-formula-feature.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ships the usable computed-column feature on top of the I1 engine: cells
whose row-data value starts with `=` in `allowFormulas` columns evaluate
through a membership-masking value overlay (rendering, sorting,
filtering, aggregation, clipboard, export), with reference-diff
invalidation, data-loss-protected editing (source seeding, valueSetter
equality guard, type-`=`-to-start-a-formula), `GridFormulaApi`, guards
for dataSource/pivoting/disableFormulas, 61 integration tests, and a
minimal docs page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pport I3)

Position context (sorted+filtered data rows × visible data columns, utility
columns and autogenerated rows excluded) with one-shot rebind passes on
sortedRowsSet/filteredRowsSet/columnVisibilityModelChange/columnsChange;
RANGE/COLUMN_VALUES evaluation with the interval dependents tier (range graph
edges expand to formula cells inside bounds in both directions); row grouping
keys and row spanning comparisons now use evaluated values, re-triggered after
formula passes (one community addition: private resetRowSpanningState API);
#REF! hardening regression pins; SUM(COLUMN_VALUES) 100k benchmark (~71ms full
pass, ~45ms single-edit diff).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MBilalShafi MBilalShafi self-assigned this Jun 15, 2026
@MBilalShafi MBilalShafi added scope: data grid Changes related to the data grid. type: new feature Expand the scope of the product to solve a new problem. plan: Premium Impact at least one Premium user. labels Jun 15, 2026
@code-infra-dashboard

code-infra-dashboard Bot commented Jun 15, 2026 •

Copy link
Copy Markdown

Deploy preview

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 🔺+488B(+0.12%) 🔺+157B(+0.13%)
@mui/x-data-grid-pro 🔺+479B(+0.09%) 🔺+170B(+0.11%)
@mui/x-data-grid-premium 🔺+3.38KB(+0.46%) 🔺+978B(+0.47%)
@mui/x-charts 0B(0.00%) 0B(0.00%)
@mui/x-charts-pro 0B(0.00%) 0B(0.00%)
@mui/x-charts-premium 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers-pro 0B(0.00%) 0B(0.00%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 0B(0.00%) 0B(0.00%)
@mui/x-scheduler 0B(0.00%) 0B(0.00%)
@mui/x-scheduler-premium 0B(0.00%) 0B(0.00%)
@mui/x-chat 0B(0.00%) 0B(0.00%)
@mui/x-license 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jun 15, 2026
@siriwatknp
siriwatknp self-requested a review June 15, 2026 08:11

@siriwatknp siriwatknp left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

I think this UX is crucial for the first version, it could use similar approach as the long-text column type via popper.

The input should grow with the formula value, otherwise, it's very hard to use.

Let me know what's the scope you want to be reviewed in this PR, UX could be a separate PR too.

@siriwatknp

Copy link
Copy Markdown
Member

I suggest spending time on improving the description to explain the scope of this feature so we are aligned on what to expect from the feature.

MBilalShafi and others added 6 commits June 22, 2026 19:28
When `escapeFormulas: false`, live formula cells now export as real Excel
formulas (`{ formula, result }`) with their references re-anchored to the
exported sheet's A1 coordinates, instead of the evaluated value. The default
(`escapeFormulas: true`) keeps the prior value-only export and the CSV/Excel
formula-injection escaping.

- Pure engine converter `serializeFormulaAstToExcel` (canonical AST to Excel A1).
- Adapter `gridFormulaExcelExport` builds the per-export coordinate layout and
  per-cell descriptors, wired into the sync and web-worker export paths.
- Stable refs stay relative, positional refs stay absolute; references outside
  the export bake `#REF!`; `COLUMN_VALUES` exports a bounded data range.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	docs/public/static/error-codes.json
#	packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx
#	packages/x-data-grid-premium/src/hooks/features/export/serializer/excelSerializer.ts
#	packages/x-data-grid-premium/src/hooks/features/rows/useGridParamsOverridableMethods.ts
@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jun 24, 2026
@github-actions

This comment was marked as outdated.

@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jun 26, 2026
MBilalShafi and others added 2 commits July 3, 2026 13:25
The formula API methods (setCellFormula, getCellFormula,
getCellFormulaResult, validateCellFormula, reevaluateFormulas) move from
GridApiPremium into GridFormulaPrivateApi until a userland use case
justifies exposing them. Reading results stays covered by the public
selectors and writing by updateRows().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restructure the page around a full syntax reference (operator precedence,
built-in function, and error-code tables), add the missing A1 notation
section with the address-to-canonical mapping, a "How it works" section
with data-flow and edit-cycle diagrams, clipboard/fill semantics, advanced
custom functions (ranges, lazy arguments, error values), a persistence
snippet, expanded limitations, and a Selectors section.

Rebuild all five demos around distinct real-world datasets (invoice,
product P&L, quarterly budget, orders with a pinned tax rate, loan
comparison). This also fixes the reference-highlighting demo, which seeded
A1 text into row data where only canonical syntax evaluates, so its
summary cell rendered #ERROR!.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks very promising!
I played with it a bit, and noticed that the cell editor doesn't play well with virtualization:

Screen.Recording.2026-07-08.at.18.44.43.mov

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this. I dug in with an instrumented reproduction and the jitter turned out to be two things interacting:

  1. When the edited row leaves the render window, the grid keeps it mounted as the zero-size "virtual focus row", but gridFocusedVirtualCellSelector reads the render context from grid state, which is synced from the virtualizer's own store one commit late (the useStoreEffect hack in useGridVirtualizer.tsx, under the TODO(v9) comment). So on the scroll tick where the row exits the render window, the editing cell briefly unmounts and then remounts.
  2. On that remount, the formula editor's focus-restore effect called focus() without preventScroll, and the browser's default scroll-into-view yanked the viewport back to the cell → the row re-entered the viewport → the next wheel tick repeated the cycle. That loop is the jitter in your video.

Fixed in 0ad689b: the editor now focuses with { preventScroll: true } (the same defense GridEditLongTextCell already uses), plus a small per-edit session mirror so the caret position survives the remount instead of snapping to the end. Scrolling is now free during a formula edit, the reference highlights scroll away with the content, and typing while scrolled away brings the viewport back to the edited cell via the browser's native caret reveal — which matches what Excel and Google Sheets do.

Worth noting: this isn't specific to the formula editor. I can reproduce the exact same snap-back editing a plain number cell with the stock GridEditInputCell on the same demo — all built-in inline editors except GridEditLongTextCell call focus() unguarded, and the one-commit keep-alive gap affects them equally. I'll open a separate issue with the repro; the core fix is probably to always pass the focused-cell descriptor to the virtualizer and let getRows' existing fresh bounds checks decide in/out (plus preventScroll in the stock editors).

@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Aug 19, 2026
@arminmeh

Copy link
Copy Markdown
Contributor

Sort pushes the window past the data band (e.g. =SUM(A1:A3) in row 5 sorted up to row 2): whole-range #REF! rather than clipping to a silent partial sum — same behavior as Sheets/Excel. Positional ($ and pinned-row) windows keep their auto-clip semantics unchanged.

I don't see this change in the latest preview

Screen.Recording.2026-08-20.at.10.36.32.mov

@arminmeh arminmeh left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the amazing work @MBilalShafi 🥇

I couldn't get deep through all the engine code. The review would take a long time. Manual testing went great. The general architecure looks good to me.

We can merge this and make the adjustment later where needed.
For such a big feature, there will for sure be request to add/tweak things.

In this last review pass, I have found some more details that I have pointed out. Some of them can be left as is.

Comment thread docsTech/data-grid-formula-feature.md Resolved
Comment thread docsTech/data-grid-formula-feature.md Resolved
* @param {string} field The column field.
* @returns {GridFormulaResult | null} The evaluation result.
*/
getCellFormulaResult: (id: GridRowId, field: string) => GridFormulaResult | null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't this the same as getCellValue?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite — they differ in two ways, and both internal callers rely on the difference:

  1. getCellValue returns the flattened display value with the full overlay applied: aggregation first, then the formula result with errors collapsed to their code string (#DIV/0!), then the plain valueGetter path. From its return you can't tell a formula cell from a plain cell literally containing #DIV/0!, and a formula evaluating to null looks like an empty cell.
  2. getCellFormulaResult returns the structured engine result ({ type: 'value' } | { type: 'error', code, message }) and null iff the cell doesn't hold a live evaluated formula. The fill logic uses that null as the "is this a formula cell" discriminator, and the Excel export needs the value/error distinction to decide what to write into the sheet.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the bundle size impact of the feature, I'm concerned that those who won't use the formula feature will have to pay the cost of the bundle size.
How about we use the dependency injection here?
The glue code can stay bundled, but the engine and components can be imported and passed to the data grid.
Something like this:

import { DataGridPremium, formulaFeature } from '@mui/x-data-grid-premium/formula';

<DataGridPremium featureDependencies={{ formula: formulaFeature }} />

Then internally, we'll be able to only import types, but not runtime engine or components.
This way, most of the formula code won't be automatically bundled.

What do you think?

@MBilalShafi MBilalShafi Aug 21, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea! Implemented it exactly in that shape:

import { DataGridPremium } from '@mui/x-data-grid-premium';
import { formulaFeature } from '@mui/x-data-grid-premium/formula';

<DataGridPremium featureDependencies={{ formula: formulaFeature }} />;

The grid now only keeps type-level references and a few thin seams:

  • featureDependencies is captured on the first render — the injected object carries hooks, so its presence can't change mid-life. Changing it later warns in dev and is ignored.
  • The three hook call sites in useDataGridPremiumComponent receive the feature's stateInitializer/usePreProcessors/useFeature with noop fallbacks. Without the feature, the formula state slice is still initialized empty, so the bundled selectors (params overlay, aggregation/filter hooks, row grouping) keep working unchanged.
  • The fill handle and the Excel export consume the formula logic through optional private API methods registered by the feature (getFilledFormulaSource, createFormulaExcelExportLayout, getCellExcelFormula), with the existing value-only behavior as the fallback.
  • FormulaBar, GRID_FORMULA_FUNCTIONS, and the formulaFunctions default moved to the /formula entry point (src/formula/index.ts is the whole subpath — the "./*" exports pattern picks it up); the types and the selectors stay on the root.
  • Without the feature, = values render as raw strings (the disableFormulas path), and a dev-only warning points at the missing dependency when formula-related props are used.

The numbers speak for themself 🔊

Before:
image

After:
image

I think we can also do the same for other features that possibly take noticable amount of bundle size.

One small trade-off: the API docs builder resolves components from the root entry point, so FormulaBar lost its generated API page — same treatment as ChartsRenderer in @mui/x-charts-premium, and its props interface was empty anyway. The docs and all demos now show the injection pattern.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't quite get the "Total Size Change" numbers in before/after though.

Total Size Change
Before +219kB (+0.54%)
After +168kB (+0.41%)

Where does the difference of 51kB come from? If that's total size of all packages in the repo, extracting formula to a separate entry point shouldn't be impacting it, right?
Any ideas what this means?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that because of premium measured twice here?

But then the weird thing is that data grid + formula (before the extraction) was adding +108kB, and after extraction formula on its own is 159kB 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... Definitely an interesting case — I dug in on this together with Fable. Seems like both oddities are measurement artifacts of the bundle-size checker, not real bytes moving around. Your "measured twice" guess is exactly right.

Where the 51kB difference comes from

Total Size Change is just the sum of the deltas of every measured entrypoint, and our config (test/bundle-size/bundle-size-checker.config.mjs) uses expand, which measures the premium package twice: once as the root entry @mui/x-data-grid-premium and once as the component entry @mui/x-data-grid-premium/DataGridPremium. The two bundles overlap almost entirely, so anything inside them is double-counted in the headline total.

The numbers reconcile perfectly:

premium root premium/DataGridPremium /formula (new) small grid/pro deltas Total
Before +109kB +108kB — ~+2kB +219kB
After +3.38kB +3.27kB +159kB ~+2kB +168kB

Before the extraction, the formula code sat inside both premium bundles, so it was counted twice (+109 and +108 ≈ 217kB). After the extraction it lives in one new entrypoint and is counted once (+159kB), while only the thin glue (~3.3kB) is still double-counted. The arithmetic checks out: 2×108 − 159 − 2×3.3 ≈ 51kB. So the total was never a measure of real shipped bytes — moving code from a twice-measured entry to a once-measured one shrinks it by construction.

Why /formula alone is 159kB when it only added +108kB inside premium

The checker builds every entrypoint as an independent webpack bundle with no cross-entry code sharing, so the two numbers measure different things:

  • +108kB (before) was the incremental cost of formula on top of premium's 729kB baseline — shared modules were already in the baseline, so the delta captured only code unique to the feature.
  • 159kB (after) is the standalone cost — the /formula bundle carries its own tree-shaken copies of everything it transitively imports: @mui/x-data-grid-pro selectors/internals, @mui/x-data-grid/internals, @mui/x-internals, @mui/material/styles, @mui/system, @mui/utils, etc.

That duplicated shared code is the ~51kB gap. Cross-check: the premium root actually shrank by 837 − 732 = 105kB, and 159 − 105 = 54kB of overlap — same ballpark.

Importantly, the duplication exists only in the measurement. An application bundler sees both entrypoints in one module graph and dedupes the shared imports, so the real cost of opting into formulas stays roughly the incremental ~108kB (~33kB gzip), and users who don't opt in genuinely save it — which was the whole point of the extraction.

@mui/infra Here's an opportunity to make bundle size report less confusing and more accurate in the future by not summing overlapping entries into one headline total.

MBilalShafi and others added 5 commits August 21, 2026 07:32
CI test_browser has been crashing (Chromium renderer OOM) with this
branch's tripled premium browser test volume. Two mitigations:

- Run the DOM-free formula engine suites (15 files, 447 tests) in jsdom
  only by excluding them from the browser config.
- Split the 197-test formula.DataGridPremium.test.tsx into five thematic
  files so per-file iframes bound the page's peak heap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to the browser OOM mitigation. Two changes:

- Exclude seven jsdom-sufficient formula unit suites from the browser
  config (117 tests; they keep running in the jsdom job). This also
  moves createFormulaEvaluation's 100k-row fixtures out of the shared
  Chromium process.
- Remove or merge ~54 integration tests whose coverage is duplicated
  by the engine suites or strictly subsumed by sibling tests, keeping
  every regression pin. The premium browser project drops from 858 to
  687 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@arminmeh

Copy link
Copy Markdown
Contributor

I think we can also do the same for other features that possibly take noticable amount of bundle size.

We should definitely do this. I think that excel export is one candidate.

Since we didn't make breaking changes for v9, I have renamed the v9 umbrella issue to v10 and added this point at the top of the list.

@JCQuintas

Copy link
Copy Markdown
Member

Thanks @arminmeh and @cherniavskii for the reviews, I'll be merging this now to prepare the release.

Any further improvements can be made on the subset of working features, and wouldn't require a 200+ files PR to be reviewed 😆

OFC feel free to keep adding comments or creating issues for improvements.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature: Formula support plan: Premium Impact at least one Premium user. scope: data grid Changes related to the data grid. type: new feature Expand the scope of the product to solve a new problem.

Projects

Status: Recently completed

Development

Successfully merging this pull request may close these issues.

7 participants