-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[DataGrid] Potential fix for grid row state propagation #15627
base: master
Are you sure you want to change the base?
Conversation
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
Deploy preview: https://deploy-preview-15627--material-ui-x.netlify.app/ |
All tests should pass now. To me this approach makes much more sense:
I was slightly confused why the master detail test started failing, but after looking into it, I'm more worried that it worked the way it did – a breeding ground for race conditions. The only reason I can think of why the other way around may have some merit is if the cells would be atomically updated based on changing row values. But since all the APIs expose the row to all the cells anyways, that cannot be the case. Also squeezed in a tiny change that fixes every row selection checkbox re-rendering on every selection model change. Was such a tiny change that I didn't bother making it a new. |
/* Start of rendering */ | ||
if (!rowNode) { | ||
return null; | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is moving this block required?
I prefer to keep functional components organized as if it were a class component:
class GridRow {
constructor() { ... }
onClick() { ... }
getCell() { ... }
render() {}
}
function GridRow {
/* initialization code */
const onClick = () => { ... }
const getCell = () => { ... }
/* render code */
}
In particular for large components, it helps readability a lot to keep all render code grouped rather than spread in-between "class methods".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like grouping logic, but here the balance is between a pretty large dead code path (defining an anyonymous function that should never be called, unless rowNode exists) vs. grouping logic. Not that clear what's more readable.
After this change, if you were to accidentally remove the rowNode check on component-level, the types would break (good imho), which is better than adding an extra conditional in the getCell that would open up the potential for using the compnent in currently unintended/undesigned ways.
Probably the right question here to ask is why the component needs to (try to) mount without a rowNode to begin with.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably the right question here to ask is why the component needs to (try to) mount without a rowNode to begin with.
Absolutely, the answer is often that the codebase went through many architectural & maintainer changes, and we haven't got around to refactoring everything.
return result; | ||
}, | ||
objectShallowCompare, | ||
const cellParams = apiRef.current.getCellParams<any, any, any, GridTreeNodeWithRender>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Haven't looked in details, but at first glance this looks wrong to me. This function does state reads, and those should always be wrapped in useGridSelector
to enable reactive updates when the state changes. Is there a reason for which this doesn't need to be wrapped? If yes, then we probably need a comment to explain why we don't wrap with useGridSelector
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's actually one of the core changes that is a step in the right direction imho, tried to explain it briefly here: #15627 (comment)
The current behaviour casts an incredibly wide net to cell-level reactivity that opens it up to race conditions, and prevents from carefully managing state propagation.
Should cell valueGetter and valueFormatter of each cell in the datagrid (renderecontext) re-evaluate whenever any part of grid state changes? (focus, tabindex, rendercontext, etc.?). They currently do, but I would argue they shouldn't. They should only be re-evaluated if the row changes (through edits or row updates).
Imagine if you define a valueFormatter that formats the cell value based on external state. Changing that external state wouldn't update the rendered cell value, but changing the focus or scrolling a bit would suddenly trigger the cell to re-render with a new value. What?!
Want to understand why the cell re-rendered? Most of the cases, the answer is that getCellParams changed. But why did they change? Good luck understanding that.
So, ideally:
- Row data is provided by row (or editing api)
- Rest of the conditions why the cell should re-render independent of the row should be carefully managed based on atomic selectors. As you can see, the scope of selectors that should trigger cell-level reactivity currently is not that wide in order to make the tests pass. This should result in both more optimised code as well as more understandable state flow within the grid.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, this explains why tabIndex
and hasFocus
are overridden with the values using useGridSelector
.
@romgrk Does this make sense to you?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Imagine if you define a valueFormatter that formats the cell value based on external state. Changing that external state wouldn't update the rendered cell value, but changing the focus or scrolling a bit would suddenly trigger the cell to re-render with a new value. What?!
The solution to that would be the approach that we've been introducing of adding some props to the grid's store reactivity model. In this case, columns[.valueFormatter]
changes should trigger a store update, though I'm a bit surprised it doesn't.
@romgrk Does this make sense to you?
No, it still doesn't. AFAICT, we would be losing reactivity for the cellParams
values other than the ones overriden with useGridSelector
values. And I see some of them for which it would be problematic, e.g. cellMode
or isEditable
.
Want to understand why the cell re-rendered? Most of the cases, the answer is that getCellParams changed. But why did they change? Good luck understanding that.
getCellParams
has been problematic for a long time (perf-wise and logic-wise), I haven't been able to get rid of it because it would be a breaking change, some of the API uses GridCellParams
. I think if we want to fix everything cleanly, we need to decompose that function into more logical parts than "here's every bit of cell data". But that would prevent cherry-picking this PR in v7.
Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that's quite true that reactivity is lost. A bunch of tests would be failing if that were the case, and cell editing wouldn't work. Any colDef
update propagates through the row as it is already. Whereas tabIndex
and focus
live in unrelated store to columns/rows.
Complete decomposition and granular cell-level reactivity would be a much bigger rewrite. My quick assessment currently is that it's not worth it and would needlessly complicate the code, as there's too many interrelated layers. One would need to ask what are the specific use cases where this would bring meaningful performance gains – I'm personally not seeing it. Maybe editing in theory, but since editing apis expose the whole row for various reasons, that's also not really the case either.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A bunch of tests would be failing if that were the case
But I don't think we have 100% test coverage, I think we're in the 80s or 90s.
I don't think that's quite true that reactivity is lost.
But there is still nothing that explicitly binds cell reactivity to those values; the binding is more like a coincidental side-effect that might break under edge-cases or unrelated refactors. If you ask me why tabIndex
works, it's because it's bound through useGridSelector
. If you ask me why isEditable
works: I don't know. And I feel uncomfortable merging code that works for unknown reasons.
Complete decomposition and granular cell-level reactivity would be a much bigger rewrite.
It doesn't feel like a big change to me, splitting getCellParams
into a few small functions is all it would take. I'll take a look at the code again tomorrow to see if I can do something v7-compatible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with the rationale that it shouldn't work due to side-effects. However, isEditable
is not the case – every input to isEditable
already triggers a re-render through the row->cell tree. It's not a side-effect by any means, but logical flow of state. Adding a selector as well just introduces extraneous work for no benefit. It will simply trigger a re-render from both the Cell within and Row as well.
On a second look, the only thing I actually think needs extracting out is cellMode
, which does probably work due to side-effects.
The mental model to me is:
- Everything in the row -> cell tree that depends directly on the row / cell store shouldn't need additional selectors, as the mere existence of a rendered cell means it's already reactive to these slices of the store.
- Additional global state that affects how the particular cell is rendered (tabIndex, focus, cellMode) needs selectors.
Does that make sense to you or am I thinking completely wrong here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or actually, cellMode
is not even a side-effect – it depends on the same slice of the state that is already selected in the GridRow
:
editCellState={editCellState} |
editCellState
prop:
const editCellState = editRowsState[rowId]?.[column.field] ?? null; |
getCellMode
prop depends on the same slice of the state:
mui-x/packages/x-data-grid/src/hooks/features/editing/useGridCellEditing.ts
Lines 254 to 261 in 2700d7e
const getCellMode = React.useCallback<GridCellEditingApi['getCellMode']>( | |
(id, field) => { | |
const editingState = gridEditRowsStateSelector(apiRef.current.state); | |
const isEditing = editingState[id] && editingState[id][field]; | |
return isEditing ? GridCellModes.Edit : GridCellModes.View; | |
}, | |
[apiRef], | |
); |
So, there are two options:
cellMode
should be a prop of a celleditRowsState
selector should be removed from the row, and there should be aeditCellState
selector withingGridCell
. On first look, I don't see why the whole row needs to re-render in response to that, since it's only used to pass a prop to cells, so it could be handled within cells themselves. Should work the same, regardless of whether the whole row is editable or only individual cells.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As long as it's an explicit reactive binding (through a prop or through a selector) I'm happy with that, but option 2 sounds better. I didn't touch the editing code when I did the reactivity refactor so there's leftover stuff from the old "reactivity" architecture (re-render everything anytime anything changes).
Once the editing state is split into an editCellState
selector, all that is left to do would be to split value
and formattedValue
logic, and we could get rid of getCellParams
in GridCell
. The other values (id, field, row, rowNode, colDef
) are all available through props.
If you want to go ahead with that please do, otherwise I'll see if I can complete this tomorrow. Btw we appreciate all the PRs you submitted, we're a bit short on time with v8 preparation and we've been slow to review & merge them, but they're all great.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed it up now. Seems to work well. There was one thing that seems to have worked due to side-effects (already before this PR) in the GridRow, moved it to a discrete selector:
Before:
const editing = apiRef.current.getRowMode(rowId) === GridRowModes.Edit; |
After:
const editing = useGridSelector(apiRef, gridRowIsEditingSelector, rowId); |
There are some additional re-renderings that can be fixed when stopping editing that should speed up editing in large grids, but that should be done when other PRs have been merged around memoization + GridRoot + forwardRef
patch.
I'm a bit hesitant to drop getCellParams
altogether in GridCell
as it opens up an easy divergence between the public API and internal logic. I refactored it a bit to make sure we don't do any unnecessary operations there though, but still keep a solid contract. Unless we need to extract value
and formattedValue
logic into selector hooks, I don't really see the point either. And I don't currently see a reason why we would need to use hooks there. If we do, we should come up with a test case that it solves, otherwise it's probably just redundant operations.
packages/x-data-grid/src/hooks/features/rows/useGridParamsApi.ts
Outdated
Show resolved
Hide resolved
packages/x-data-grid-pro/src/components/GridDetailPanelToggleCell.tsx
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@romgrk It should be safe to cherry-pick this PR to v7 once we merge it. Any objections?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See comment above.
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
29a7422
to
72507ac
Compare
b21b65f
to
f89f70e
Compare
packages/x-data-grid-pro/src/components/GridDetailPanelToggleCell.tsx
Outdated
Show resolved
Hide resolved
@romgrk, there's one last bug I'd love to fix as a part of this PR (present already before the PR) If you pin a column to left, you see a visible flash of content: There's two issues:
Just quickly playing around, this seems to fix the 2nd issue, but I'm not sure if there's a better way: const visibleColumnsSelector = React.useCallback(
() => () =>
listView
? [gridListColumnSelector(apiRef.current.state)!]
: gridVisibleColumnDefinitionsSelector(apiRef),
[apiRef, listView],
);
const visibleColumns = useGridSelector(apiRef, visibleColumnsSelector); Any thoughts? |
I actually don't even understand why the above works. How can selector even be a function that returns a function? Was a copy-paste mistake, but it still returns visible columns. What does work however is to call apiRef.current.setPinnedColumns(newPinnedColumns);
apiRef.current.requestPipeProcessorsApplication('hydrateColumns'); Seems to fix both of the issues. I'm not sure what's currently even calling the hydration, but it happens a couple of render passes later. |
Aha, I understand what's happening currently (I think). Pinned columns change -> changes With the above change now |
After more debugging, I finally understand what trigger column hydration here.
I will make triggering |
Ok, managed to fix this. With the memoization PR together, changing pinning will now happen in one render pass, instead of 3-4 I think (meaning all rows re-rendered 4 times due to it). I also refactored Please take a look when you have a moment @romgrk 🙏 Edit: will check tomorrow why the flex col tests started failing |
The last fix I added as a part of this PR could be considered breaking, although could also potentially be classified as a bug.
This has two negative implications:
None of the internal consumers actually seem to depend on the width without pinned columns, but rather on the actual inner viewport width. Meaning that there's likely many unnecessary operations happening when pinned columns are present, resized or modified. On a slightly different note. It also seems like the below code doesn't seem to serve any function, as it always returns 0 for <div
role="presentation"
className={gridClasses.cellOffsetLeft}
style={{ width: offsetLeft }}
/> |
After some further thoughts on #15616, probably better to try and fix the root cause of the row existence checks instead.
@romgrk, what if anything am I breaking here? Fixes the need for try/catch and out of order state propagation (currently on removing a row, first all the cells re-render to null, then the rows unmount. After this change, a row would be responsible for cells being rendered or not).