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
@@ -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;
}