diff --git a/src/utils/isRealtimePayloadEqual.test.ts b/src/utils/isRealtimePayloadEqual.test.ts new file mode 100644 index 000000000..9945b5b77 --- /dev/null +++ b/src/utils/isRealtimePayloadEqual.test.ts @@ -0,0 +1,54 @@ +import type { RealTimeTaskResponse } from '@/hoc/RealTime' +import { isTaskPayloadEqual } from '@/utils/isRealtimePayloadEqual' +import { RealtimePostgresChangesPayload } from '@supabase/supabase-js' + +const buildTaskPayload = ({ + newPayload, + oldPayload, +}: { + newPayload: Record + oldPayload: Record +}): RealtimePostgresChangesPayload => + ({ + commit_timestamp: '2026-07-08T12:00:00.000Z', + errors: [], + eventType: 'UPDATE', + new: newPayload, + old: oldPayload, + schema: 'public', + table: 'Tasks', + }) as unknown as RealtimePostgresChangesPayload + +describe('isTaskPayloadEqual', () => { + it('ignores no-op updates for task fields', () => { + const task = { + id: 'task-1', + title: 'Prepare docs', + workflowStateId: 'workflow-state-1', + associations: [{ companyId: 'company-1' }], + isShared: true, + } + + expect(isTaskPayloadEqual(buildTaskPayload({ newPayload: { ...task }, oldPayload: { ...task } }))).toBe(true) + }) + + it('detects changed task fields', () => { + expect( + isTaskPayloadEqual( + buildTaskPayload({ + newPayload: { id: 'task-1', title: 'Prepare docs' }, + oldPayload: { id: 'task-1', title: 'Prepare draft' }, + }), + ), + ).toBe(false) + }) + + it('does not recurse through cyclic payload values', () => { + const newPayload: Record = { id: 'task-1', title: 'Prepare docs' } + const oldPayload: Record = { id: 'task-1', title: 'Prepare docs' } + newPayload.self = newPayload + oldPayload.self = oldPayload + + expect(() => isTaskPayloadEqual(buildTaskPayload({ newPayload, oldPayload }))).not.toThrow() + }) +}) diff --git a/src/utils/isRealtimePayloadEqual.ts b/src/utils/isRealtimePayloadEqual.ts index 6ef32e0c5..8af7ef05f 100644 --- a/src/utils/isRealtimePayloadEqual.ts +++ b/src/utils/isRealtimePayloadEqual.ts @@ -1,15 +1,82 @@ -import { RealTimeTaskResponse } from '@/hoc/RealTime' -import { RealTimeTemplateResponse } from '@/hoc/RealtimeTemplates' +import type { RealTimeTaskResponse } from '@/hoc/RealTime' +import type { RealTimeTemplateResponse } from '@/hoc/RealtimeTemplates' import { RealtimePostgresChangesPayload } from '@supabase/supabase-js' -import deepEqual from 'deep-equal' + +const taskRealtimeFields = [ + 'id', + 'label', + 'workspaceId', + 'assigneeId', + 'internalUserId', + 'clientId', + 'companyId', + 'assigneeType', + 'title', + 'body', + 'createdById', + 'workflowStateId', + 'assignedAt', + 'completedAt', + 'dueDate', + 'lastActivityLogUpdated', + 'lastSubtaskUpdated', + 'createdAt', + 'updatedAt', + 'deletedAt', + 'parentId', + 'subtaskCount', + 'path', + 'isArchived', + 'lastArchivedDate', + 'source', + 'templateId', + 'completedBy', + 'completedByUserType', + 'archivedBy', + 'deletedBy', + 'associations', + 'isShared', +] as const + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const toRecord = (value: unknown): Record | undefined => (isRecord(value) ? value : undefined) + +const arePrimitiveRecordValuesEqual = (newValue: Record, oldValue: Record): boolean => { + const keys = [...new Set([...Object.keys(newValue), ...Object.keys(oldValue)])] + return keys.every((key) => Object.is(newValue[key], oldValue[key])) +} + +const areFlatArraysEqual = (newValue: unknown[], oldValue: unknown[]): boolean => { + if (newValue.length !== oldValue.length) return false + return newValue.every((item, index) => { + const oldItem = oldValue[index] + if (isRecord(item) && isRecord(oldItem)) { + return arePrimitiveRecordValuesEqual(item, oldItem) + } + return Object.is(item, oldItem) + }) +} + +const areRealtimeValuesEqual = (newValue: unknown, oldValue: unknown): boolean => { + if (Object.is(newValue, oldValue)) return true + if (Array.isArray(newValue) && Array.isArray(oldValue)) { + return areFlatArraysEqual(newValue, oldValue) + } + if (isRecord(newValue) && isRecord(oldValue)) { + return arePrimitiveRecordValuesEqual(newValue, oldValue) + } + return false +} export function isTaskPayloadEqual( payload: RealtimePostgresChangesPayload, ): boolean { - const newPayload = payload.new - const oldPayload = payload.old + const newPayload = toRecord(payload.new) + const oldPayload = toRecord(payload.old) if (!newPayload || !oldPayload) return true - return deepEqual(newPayload, oldPayload) + return taskRealtimeFields.every((field) => areRealtimeValuesEqual(newPayload[field], oldPayload[field])) } export function isTemplatePayloadEqual(payload: RealtimePostgresChangesPayload): boolean {