fix(kanban): preserve scroll on board re-init + propagate same-column reorders via SSE (#20637)

closes
https://discord.com/channels/1130383047699738754/1504130730840821860


https://github.com/user-attachments/assets/d5833031-01c6-4e46-b699-c29c42435a53





## Summary

Fixes two related issues with the kanban (board view) collaboration
experience:

1. **Scroll-to-top on every data change** —
`triggerRecordBoardInitialQuery` always scrolled the board to the top,
even when re-initializing for a single-record data change (SSE echo of
your own mutation, a collaborator's update). Scroll reset only makes
sense when the dataset itself changes (filter / sort / group).
2. **Same-column reorders by other users did not propagate** — the
server's diff function stripped `FieldMetadataType.POSITION`, so
position-only updates produced empty `updatedFields` and short-circuited
event emission entirely. SSE clients never received them.

## What's in here

- **Frontend** — `useTriggerRecordBoardInitialQuery` now exposes a
`triggerRecordBoardInitialQueryWithoutScrollReset` variant; data-driven
re-inits in `RecordBoardDataChangedEffect` use it, while genuine filter
/ sort / group changes keep the scroll-resetting
`triggerRecordBoardInitialQuery`. `getRecordBoardEffectsForUpdateInputs`
classifies each update as `trigger-initial-query` / `reposition-records`
/ `none`. For position- or group-only changes we skip the re-query and
reposition records in place in the store
(`useRepositionRecordsOnBoard`), which avoids the flicker and preserves
scroll.
- **Server** — removes `POSITION` from `objectRecordChangedValues`'
strip list, so position-only updates emit a non-empty diff and flow
through SSE. Position is now treated as a field like any other across
all event consumers (SSE, webhooks, workflows, logic functions); a
trigger with an explicit field filter still excludes it.
This commit is contained in:
nitin
2026-06-11 12:56:51 +05:30
committed by GitHub
parent 9100fb1e9f
commit 20c83e1f86
14 changed files with 670 additions and 137 deletions
@@ -153,7 +153,7 @@ describe('objectRecordChangedValues', () => {
expect(result).toEqual(expectedChanges);
});
it('ignores changes to POSITION fields', () => {
it('detects changes to POSITION fields', () => {
const positionFieldId = 'position-field-id';
const positionUniversalId = 'position-universal-id';
@@ -198,8 +198,56 @@ describe('objectRecordChangedValues', () => {
expect(result).toEqual({
name: { before: 'Original', after: 'Updated' },
position: { before: 1, after: 5 },
});
});
it('returns a non-empty diff for a position-only change', () => {
const positionFieldId = 'position-field-id';
const positionUniversalId = 'position-universal-id';
const objectMetadataWithPosition: FlatObjectMetadata = {
...mockObjectMetadata,
fieldIds: [positionFieldId],
};
const flatFieldMetadataMapsWithPosition: FlatEntityMaps<FlatFieldMetadata> =
{
byUniversalIdentifier: {
[positionUniversalId]: {
id: positionFieldId,
name: 'position',
type: FieldMetadataType.POSITION,
universalIdentifier: positionUniversalId,
} as FlatFieldMetadata,
},
universalIdentifierById: {
[positionFieldId]: positionUniversalId,
},
universalIdentifiersByApplicationId: {},
};
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516n',
position: 1,
name: 'Unchanged',
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516n',
position: 5,
name: 'Unchanged',
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
objectMetadataWithPosition,
flatFieldMetadataMapsWithPosition,
);
expect(result).toEqual({
position: { before: 1, after: 5 },
});
expect(result).not.toHaveProperty('position');
});
describe('with a MANY_TO_ONE relation field', () => {
@@ -112,7 +112,6 @@ export const objectRecordChangedValues = (
if (
key === 'updatedAt' ||
key === 'searchVector' ||
field?.type === FieldMetadataType.POSITION ||
(isDefined(field) && isManyToOneRelationField(field)) ||
field?.type === FieldMetadataType.RELATION ||
field?.type === FieldMetadataType.MORPH_RELATION
@@ -333,6 +333,97 @@ describe('transformEventBatchToEventPayloads', () => {
});
});
describe('position-only updates', () => {
it('should include position-only events when the trigger has no updatedFields filter', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
name: 'company.updated',
events: [
createMockEvent({
recordId: 'record-1',
properties: { after: {}, updatedFields: ['position'] },
}),
createMockEvent({
recordId: 'record-2',
properties: { after: {}, updatedFields: ['name'] },
}),
],
});
const logicFunctions = [
createMockLogicFunction({
databaseEventTriggerSettings: { eventName: 'company.updated' },
}),
];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
logicFunctions,
});
expect(result).toHaveLength(2);
expect(
result.map((r) => (r.payload as ObjectRecordEvent).recordId),
).toEqual(['record-1', 'record-2']);
});
it('should include events that change other fields alongside position', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
name: 'company.updated',
events: [
createMockEvent({
recordId: 'record-1',
properties: { after: {}, updatedFields: ['position', 'name'] },
}),
],
});
const logicFunctions = [
createMockLogicFunction({
databaseEventTriggerSettings: { eventName: 'company.updated' },
}),
];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
logicFunctions,
});
expect(result).toHaveLength(1);
});
it('should exclude position-only events when the trigger filters on another field', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
name: 'company.updated',
events: [
createMockEvent({
recordId: 'record-1',
properties: { after: {}, updatedFields: ['position'] },
}),
createMockEvent({
recordId: 'record-2',
properties: { after: {}, updatedFields: ['name'] },
}),
],
});
const logicFunctions = [
createMockLogicFunction({
databaseEventTriggerSettings: {
eventName: 'company.updated',
updatedFields: ['name'],
},
}),
];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
logicFunctions,
});
expect(result).toHaveLength(1);
expect(
result.map((r) => (r.payload as ObjectRecordEvent).recordId),
).toEqual(['record-2']);
});
});
describe('edge cases', () => {
it('should return empty array when no logic functions provided', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch();
@@ -53,11 +53,11 @@ const filterEventsByUpdatedFields = ({
operation: string;
triggerUpdatedFields?: string[];
}): ObjectRecordEvent[] => {
if (
operation !== 'updated' ||
!isDefined(triggerUpdatedFields) ||
triggerUpdatedFields.length === 0
) {
if (operation !== 'updated') {
return events;
}
if (!isDefined(triggerUpdatedFields) || triggerUpdatedFields.length === 0) {
return events;
}