Summary
When record.set() is called on a grouper field while the store has events suspended via beginBatch() / endBatch(), the affected records are not re-positioned under their new group header on endBatch(). They stay under their original group header until the next unbatched mutation on that field fires a change event, at which point all deferred re-grouping happens at once.
The same pattern works correctly for filtering and sorting — both of those are reapplied directly from Store.onUpdateRecord(), not via a change-event listener, so suspending events doesn't affect them.
I'd like to know whether this is intentional ("if you suspend events, you own the side-effects") or a bug worth fixing in endBatch().
Investigation
StoreGroup.onDataChanged (in data/mixin/StoreGroup.js) is a listener on the store's own change event:
construct(config) {
super.construct(config);
this.ion({
change: 'onDataChanged',
thisObj: this,
});
}
onDataChanged({ changes, action }) {
if (this.isGrouped && (
!changes && resortActions[action] ||
changes && this.groupers.some(g => g.rootFieldName in changes))) {
this.sort();
}
}
Store.beginBatch() calls suspendEvents(), so each record.set() inside the batch fires onModelChange but the change event is suppressed. endBatch() then fires only a refresh event with action: 'batch', which does not satisfy either branch in onDataChanged. Result: the deferred re-group is silently lost.
By contrast, the filter and sort branches in Store.onUpdateRecord() are called directly from onModelChange (above the if (!silent) check), so they survive the suspension:
onModelChange(record, toSet, wasSet, silent, fromRelationUpdate) {
...
me.onUpdateRecord(record, wasSet); // calls filter()/sort()
if (!silent) {
...
me.trigger('change', { action: 'update', ... }); // suppressed during batch
}
}
It feels asymmetric that a batch wipes out grouping but not sorting/filtering.
Minimal repro
import { Grid } from '@bryntum/grid-thin';
const grid = new Grid({
appendTo : document.body,
features : { group: 'city' },
columns : [
{ text: 'Name', field: 'name', flex: 1 },
{ text: 'City', field: 'city', flex: 1 },
],
data : [
{ id: 1, name: 'Alice', city: 'London' },
{ id: 2, name: 'Bob', city: 'London' },
{ id: 3, name: 'Charlie', city: 'Paris' },
],
});
const { store } = grid;
const [alice, bob] = [store.getById(1), store.getById(2)];
// Pre: alice and bob both sit under the London group header.
console.log(alice.instanceMeta(store).groupParent.meta.groupRowFor); // 'London'
// --- without the batch: row gets moved into the Paris group on the next render.
// alice.set('city', 'Paris');
// --- with the batch: row stays under the London header until something else
// mutates a grouper field outside the batch.
store.beginBatch();
alice.set('city', 'Paris');
bob.set('city', 'Paris');
store.endBatch();
console.log(alice.instanceMeta(store).groupParent.meta.groupRowFor); // still 'London'
console.log(bob.instanceMeta(store).groupParent.meta.groupRowFor); // still 'London'
If you then run any unbatched record.set('city', ...) on a single record, the deferred re-group fires once and everything including alice and bob ends up under the Paris header.
Workaround we're shipping
Where we have a batched bulk-edit operation, we follow endBatch() with an explicit store.group() if the touched field matches an active grouper:
store.beginBatch();
for (const record of records) {
record.set(fieldName, value);
}
store.endBatch();
if (
store.isGrouped &&
store.groupers.some(g => g.field === fieldName)
) {
store.group(); // catch up on the deferred re-group
}
This is fine for our use case but it means every consumer that batches has to remember to do this — which is precisely the kind of thing endBatch() already handles for sort/filter.
Questions
Is the current behaviour intentional, or worth filing as a defect?
If intentional, would you consider documenting it on beginBatch() / endBatch() so consumers know they need to re-group manually after batched grouper-field mutations? The asymmetry vs sort/filter is the confusing part.
Thanks!