Skip to content

[data grid] Fix updateRows stripping class prototypes from rows in datasource mode - #22288

Merged
michelengelen merged 4 commits into
mui:masterfrom
mixelburg:fix/preserve-row-prototype-on-update
Jul 28, 2026
Merged

michelengelen merged 4 commits into
mui:masterfrom
mixelburg:fix/preserve-row-prototype-on-update

Conversation

@mixelburg

Copy link
Copy Markdown
Contributor

Fixes #22282

In dataSource mode, updateRows merges new rows with existing ones using object spread ({ ...oldRow, ...partialRow }), which discards the prototype of partialRow. This breaks class-based row models that rely on prototype getters or methods.

Changed to Object.create(Object.getPrototypeOf(partialRow)) followed by Object.assign to preserve the prototype of the incoming row while still merging properties from both old and new rows.

@code-infra-dashboard

code-infra-dashboard Bot commented May 1, 2026 •

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-22288--material-ui-x.netlify.app/
QR code for https://deploy-preview-22288--material-ui-x.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 🔺+123B(+0.03%) 🔺+40B(+0.03%)
@mui/x-data-grid-pro 🔺+123B(+0.02%) 🔺+39B(+0.03%)
@mui/x-data-grid-premium 🔺+123B(+0.02%) 🔺+44B(+0.02%)
@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.

// Update the data row lookups.
dataRowIdToModelLookup[id] = { ...oldRow, ...partialRow };
// Preserve prototype of partialRow to support class-based row models
const merged = Object.create(Object.getPrototypeOf(partialRow));

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.

Codex review:

This fixes the simple public-field repro, but it is not the same as preserving the incoming row instance.

Two gaps remain:

  1. If a class row uses private fields, prototype getters/methods still throw because Object.create(Person.prototype) does not brand the object with #private fields.
  2. If the existing row is a class instance but the update is a documented partial object (updateRows([{ id, first }])), the new row still gets Object.prototype, so the stored class prototype is lost.
    This conflicts with updateRows() being documented for partial row updates, and with the original row cache preserving input row objects directly (gridRowsUtils.ts:111-114)

So it seems the fix so far only works if every update is a class instance, but it doesn't work for plain partial updates

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.

One more subtle edge case: If oldRow has a custom prototype but partialRow is a plain object, oldRow's prototype is silently discarded. The fix always uses partialRow's prototype. This is arguably correct semantics (the update dictates the shape of the row), but worth being aware of.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in commit c7a8f2b.

Gap 1 (private fields): This cannot be fixed through a spread-merge approach. Private class fields (#field) are brand-checked against the original instance — any Object.create() + spread will throw when those fields are accessed. A code comment is now added explaining this limitation: rows that rely on private fields must supply a fully-constructed class instance as partialRow (rather than a plain-object partial), at which point rule 1 of the new logic takes over and the instance prototype is used directly.

Gap 2 (plain partialRow silently discards oldRow prototype): Fixed. The new logic checks Object.getPrototypeOf(partialRow) === Object.prototype (i.e. "is partialRow a plain object?"). If so, it uses oldRow's prototype for the merged object instead, preserving the class prototype chain of the existing row. If partialRow is itself a class instance, its prototype wins (the caller is passing a fully-constructed instance).

Three regression tests added in rows.DataGrid.test.tsx covering all three cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in commit c7a8f2b.

The fix now distinguishes between a plain-object partialRow and a class-instance partialRow:

  • partialRow is plain → use Object.getPrototypeOf(oldRow) for the merged object, so oldRow's class prototype is preserved.
  • partialRow is a class instance → use partialRow's prototype (caller owns the prototype chain).
  • Both plain → result is a plain object (no change in behaviour).

Tests added for all three cases in rows.DataGrid.test.tsx.

@mj12albert mj12albert added the scope: data grid Changes related to the data grid. label May 2, 2026

@michelengelen michelengelen 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.

Apart from the mentioned change requests could you please add some tests for the different cases?

// Update the data row lookups.
dataRowIdToModelLookup[id] = { ...oldRow, ...partialRow };
// Preserve prototype of partialRow to support class-based row models
const merged = Object.create(Object.getPrototypeOf(partialRow));

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.

One more subtle edge case: If oldRow has a custom prototype but partialRow is a plain object, oldRow's prototype is silently discarded. The fix always uses partialRow's prototype. This is arguably correct semantics (the update dictates the shape of the row), but worth being aware of.

@MBilalShafi
MBilalShafi self-requested a review May 7, 2026 07:49
@jvskriubakken

Copy link
Copy Markdown
Contributor

@michelengelen, that edge case is worth dwelling on — I wonder if it's actually more central than it looks at first.
Partial patches are the documented use of updateRows: "if you want to only update part of the rows, you can use the apiRef.current.updateRows() method." So the fix as written changes the behaviour of the documented call shape (partial patches against class-instance rows now silently lose the prototype), in service of fixing a different call shape (full instance replacement, like the one I described in #22282). The merge can't tell those apart from the prototype alone.

Would something like this work as a small refinement? Falling back to oldRow's prototype when partialRow is a plain object:

const partialProto = Object.getPrototypeOf(partialRow);
const proto =
  partialProto === Object.prototype
    ? Object.getPrototypeOf(oldRow)
    : partialProto;
const merged = Object.create(proto);
Object.assign(merged, oldRow, partialRow);

That would handle:
• full class-instance replacement (new Person(...)) — keeps Person.prototype
• plain partial patch against a class-instance row ({ id, field }) — keeps oldRow's prototype
• plain-object row updated with plain-object patch — unchanged

It still can't disambiguate a cross-class patch (Person → Manager), but that feels exotic enough that "pass a full instance if you mean that" seems reasonable.

The deeper concern — also raised by @mj12albert re: private fields — is that any heuristic merge has to guess what the caller meant. That's why in #22282 I suggested an explicit _action: 'replace' branch alongside 'delete': callers who want identity-preserving row swap opt in explicitly, Object.assign is bypassed entirely (so private fields survive), and the default updateRows semantics for partial patches stay exactly as documented.

@jvskriubakken

Copy link
Copy Markdown
Contributor

How can we go forward on this one?

@michelengelen

Copy link
Copy Markdown
Member

How can we go forward on this one?

Hey @jvskriubakken it seems the author is not responding, so we either pick this up ourselves, or if you have some free time please feel free to open up a PR yourself. I'll see if i can pick this up next week.

@jvskriubakken

Copy link
Copy Markdown
Contributor

If I open up a PR my self, do I then open of from this one? Or from scratch from master?

@mixelburg

Copy link
Copy Markdown
Contributor Author

hi, sorry for being offline for some time
I'm back, let me take a look

@mixelburg

Copy link
Copy Markdown
Contributor Author

Tests added in commit c7a8f2b under a new describe("prototype preservation on updateRows") block in packages/x-data-grid/src/tests/rows.DataGrid.test.tsx:

  1. Class instance + plain partial — verifies instanceof and prototype methods still work after a plain-object updateRows call.
  2. Plain row + class-instance partial — verifies the merged row adopts the class-instance prototype.
  3. Plain + plain — verifies Object.getPrototypeOf(result) === Object.prototype (no regression).

Also addressed the two gaps from @mj12albert and the edge case from @michelengelen (inline) — see inline replies for details.

@jvskriubakken

Copy link
Copy Markdown
Contributor

@mixelburg Would you be able to look into the failing build?

mixelburg and others added 2 commits June 13, 2026 00:17
…models

Object spread { ...oldRow, ...partialRow } discards the prototype of
partialRow, breaking class-based row models with prototype getters or
methods. Use Object.create to preserve partialRow's prototype when
merging updates.

Fixes mui#22282
…ject, add tests

When `updateRows` is called with a plain-object partial update, the previous fix
always used `partialRow`'s prototype (Object.prototype), silently discarding the
class prototype of `oldRow`.

New logic:
- If partialRow is a class instance (non-plain proto), use its prototype — caller
  owns the prototype chain.
- If partialRow is a plain object, use oldRow's prototype so the existing row's
  class is preserved.
- Both plain → result is plain (no-op).

Adds a comment explaining that private class fields (#field) cannot be preserved
through spread-merge; those rows must supply a fully-constructed instance as
partialRow.

Adds three regression tests covering:
1. Class instance updated with plain-object partial → prototype preserved from oldRow
2. Plain-object row updated with class-instance partial → prototype from partialRow
3. Plain + plain → result is plain object
@mixelburg
mixelburg force-pushed the fix/preserve-row-prototype-on-update branch from c7a8f2b to eb7b463 Compare June 13, 2026 00:17
@mixelburg

Copy link
Copy Markdown
Contributor Author

Hi! Apologies for the delay. I will look into the failing build and push a fix. Thanks for the ping!

@mixelburg

Copy link
Copy Markdown
Contributor Author

Hi @mj12albert @michelengelen! All review feedback was addressed back in May (commits c7a8f2b and earlier). Is there anything else needed here?

@michelengelen michelengelen added the type: bug It doesn't behave as expected. label Jul 27, 2026
@michelengelen michelengelen changed the title [data grid] Fix updateRows stripping class prototypes from rows in dataSource mode [data grid] Fix updateRows stripping class prototypes from rows in datasource mode Jul 27, 2026

@michelengelen michelengelen 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.

LGTM ... seems the concerns are addressed clearly

@michelengelen

Copy link
Copy Markdown
Member

Seems the only blocking step is CI... could you run pnpm prettier --write and commit the changes to unblock it? Thanks!

@mixelburg

Copy link
Copy Markdown
Contributor Author

Done! Ran prettier on the test file and pushed the formatting changes. Let's see if CI passes now.

@michelengelen
michelengelen merged commit bbff8c0 into mui:master Jul 28, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: data grid Changes related to the data grid. type: bug It doesn't behave as expected.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[data grid] apiRef.updateRows strips class prototypes from rows in dataSource mode (no identity-preserving update API)

4 participants