Repository navigation
[DataGridPremium] Formula support - #22807
Conversation
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>
Deploy previewBundle size
Check out the code infra dashboard for more information about this PR. |
|
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
|
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. |
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>
… proper text sync
# 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
This comment was marked as outdated.
This comment was marked as outdated.
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>
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Thanks for catching this. I dug in with an instrumented reproduction and the jitter turned out to be two things interacting:
- When the edited row leaves the render window, the grid keeps it mounted as the zero-size "virtual focus row", but
gridFocusedVirtualCellSelectorreads the render context from grid state, which is synced from the virtualizer's own store one commit late (theuseStoreEffecthack inuseGridVirtualizer.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. - 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).
I don't see this change in the latest preview Screen.Recording.2026-08-20.at.10.36.32.mov |
There was a problem hiding this comment.
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.
| * @param {string} field The column field. | ||
| * @returns {GridFormulaResult | null} The evaluation result. | ||
| */ | ||
| getCellFormulaResult: (id: GridRowId, field: string) => GridFormulaResult | null; |
There was a problem hiding this comment.
isn't this the same as getCellValue?
There was a problem hiding this comment.
Not quite — they differ in two ways, and both internal callers rely on the difference:
getCellValuereturns 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 plainvalueGetterpath. From its return you can't tell a formula cell from a plain cell literally containing#DIV/0!, and a formula evaluating tonulllooks like an empty cell.getCellFormulaResultreturns the structured engine result ({ type: 'value' } | { type: 'error', code, message }) andnulliff the cell doesn't hold a live evaluated formula. The fill logic uses thatnullas the "is this a formula cell" discriminator, and the Excel export needs the value/error distinction to decide what to write into the sheet.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
featureDependenciesis 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
useDataGridPremiumComponentreceive the feature'sstateInitializer/usePreProcessors/useFeaturewith noop fallbacks. Without the feature, theformulastate 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 theformulaFunctionsdefault moved to the/formulaentry point (src/formula/index.tsis 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 (thedisableFormulaspath), and a dev-only warning points at the missing dependency when formula-related props are used.
The numbers speak for themself 🔊
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
/formulabundle carries its own tree-shaken copies of everything it transitively imports:@mui/x-data-grid-proselectors/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.
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>
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. |
|
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. |




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:
hooks/features/formula/engine/: tokenizer, recursive-descent parser (Excel operator precedence, including the-2^2 = 4/2^3^2 = 64quirks), 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.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
processRowUpdate, and controlled-rows scenarios work with zero extra code, because history replays raw row values and evaluation re-derives results.@mui/x-internals(that package is MIT — publishing the engine there would let anyone build formula support on the MIT grid).REF(COLUMN("field"), ROW("id"))for identity-stable refs,REF(COLUMN_POSITION(i), ROW_POSITION(j))for position-fixed refs, plusRANGE(...),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.getCellValue/getRowValue/getRowFormattedValue: the branch checks lookup-entry presence, not value truthiness, so a formula evaluating tonullcan never leak the raw=source into sorting/filter/export. Error results bypassvalueFormatterand render their error code.setStatewith 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
""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.#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).parseFormula/evaluateFormulahonor a never-throws contract.Cross-row references, ranges, position context
sortedRowsSet/filteredRowsSetthe 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.#REF!in dependents only; re-adding it recovers.Editing
=; a column's customrenderEditCellalways wins over it. Non-formula cells keep their original editor.valueSetterequality guard protects formulas on commit paths that bypass the editor (row edit mode, programmatic commits) — the two data-loss traps of naive implementations.contenteditableeditor (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.disableFormulaAutocompleteopt-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 optionalsignature/description/categorymetadata. Opt-out exists because the open popup consumes Enter/Tab/Arrows instead of committing/navigating.Formula bar
slotProps.toolbar.formulaBar, or composed anywhere (including portals) through the exportedFormulaBarcomponent. Component docs + API pages included.valueSetter, editability checks,processRowUpdate, single undo step).canUpdateFocusveto protects the edit while the bar owns focus).A1 notation (opt-in
formulaA1Notation, default off)A1,$A$1,A1:B5,A:A; commit freezes to canonical. Row data, copy, export, andgetCellFormulaalways carry canonical — A1 is never stored.A1→ stable ref (relative),$-anchored axis → positional ref (absolute), per axis ($A1/A$1mix).ROW_POSITION/A1 numbers resolve to.=price * quantity) even in A1 mode — an A1 cell address would freeze them to a specific row.Fill handle + keyboard fill
(rowDelta, columnDelta); positional ($) refs stay fixed; range endpoints shift independently (so=SUM($A$1:A1)running totals work); overshoot bakes#REF!.allowFormulasreceives the source's evaluated value instead of an inert=string; one gesture across mixed columns does the right thing per cell.Excel export
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 cachedresultmeans values display without recalc.escapeFormulasoption (no new prop): the default (true) keeps today's value-only, injection-escaped output byte-identical;falsealready 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.Public API surface
GridColDefallowFormulas?: boolean(defaultfalse— opt-in per column, since implicit enablement would silently reinterpret existing=-prefixed data)disableFormulas(defaultfalse),formulaFunctions(custom function registry, replacement semantics likeaggregationFunctions),formulaA1Notation(defaultfalse),disableFormulaAutocomplete(defaultfalse)FormulaBar(+FormulaBarProps), wired to the default toolbar viaslotProps.toolbar.formulaBarformulaEvaluated: { changedCells: GridCellCoordinates[] }gridFormulaStateSelector,gridFormulaLookupSelector,gridCellFormulaResultSelector(+ theGridFormulaResult/GridFormulaState/function-definition types and theGRID_FORMULA_FUNCTIONSbuilt-in registry)formulaBarLabel,formulaBarInputLabel,formulaBarAddressLabelsetCellFormula,getCellFormula,getCellFormulaResult,validateCellFormula,reevaluateFormulas) is private for now: reading results is covered by the public selectors and writing byupdateRows(). It can be promoted later if a userland use case appears.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
canUpdateFocuspipe consult inGridCell's focus-steal effect (formula bar / floating editor focus survival), the privateresetRowSpanningStateAPI (row-spanning re-application after evaluation passes), aformulaBarpass-through on the toolbars, CSS classes, the three locale keys, and afocusElementinternals export.Scope
Implemented in this PR
GridFormulaApiRANGE/COLUMN_VALUES, position context + single-pass rebindcontenteditableeditorrenderEditCelltakes precedence over the formula editorescapeFormulas: false); CSV/clipboard/print export evaluated valuesformulaFunctionsFormulaBar+slotProps.toolbar.formulaBar) with draft preview and edit-mode mirroringRemaining before merge (in this PR)
Follow-ups (out of scope for v1, separate PRs)
volatileregistry flag and its semantics already exist for custom functionsKnown limitations (documented on the docs page / in code docs)
dataSourceor while pivoting is active (evaluation is disabled with a dev warning).,argument separator,.decimal).contenteditablerather than<input />— grid-level undo/redo of committed values works as usual.valueGetteris ignored for its formula cells (dev warning); it applies normally to plain cells.COLUMN_VALUES/RANGEemit 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.escapeFormulasis 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:
b438be388e7a8e8fd3859a6351abee3341eb6dbf978f62f8b6f32abf07d9a4ad0f52f26f71a3a89crenderEditCellprecedence227d2baa92f156094b97contenteditableeditorae71d71d293fd2cb22f70ad689b45b60f4f247e12dfe6b050a)9bcaa60f38842bdd72f4formulaEvaluated, doc fixes52e90230e6(plus small fix-up commits and
mastermerges)Suggested reading order:
engine/index.ts(public engine surface) →createFormulaEvaluation.ts+useGridFormula.ts(pass pipeline) → the overlay inuseGridParamsOverridableMethods.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 in842bdd72f4/ae71d71d29.