From ac5705d0b27acf9ec433c66a753e8a17e99a7164 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 12:07:43 +0000 Subject: [PATCH 1/3] Fix realtime payload equality stack overflow Co-authored-by: Neil Raina --- src/utils/isRealtimePayloadEqual.test.ts | 54 +++++++++++++++++++ src/utils/isRealtimePayloadEqual.ts | 69 +++++++++++++++++++++++- 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 src/utils/isRealtimePayloadEqual.test.ts diff --git a/src/utils/isRealtimePayloadEqual.test.ts b/src/utils/isRealtimePayloadEqual.test.ts new file mode 100644 index 000000000..4eec164e9 --- /dev/null +++ b/src/utils/isRealtimePayloadEqual.test.ts @@ -0,0 +1,54 @@ +import { 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: null, + eventType: 'UPDATE', + new: newPayload, + old: oldPayload, + schema: 'public', + table: 'Tasks', + }) 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..46f4f5aef 100644 --- a/src/utils/isRealtimePayloadEqual.ts +++ b/src/utils/isRealtimePayloadEqual.ts @@ -1,7 +1,72 @@ import { RealTimeTaskResponse } from '@/hoc/RealTime' import { 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 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, @@ -9,7 +74,7 @@ export function isTaskPayloadEqual( const newPayload = payload.new const oldPayload = 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 { From 3bc468040993259b582ba41dbb4fc84156dcb2c2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 12:08:46 +0000 Subject: [PATCH 2/3] Fix realtime equality test typings Co-authored-by: Neil Raina --- src/utils/isRealtimePayloadEqual.test.ts | 6 +++--- src/utils/isRealtimePayloadEqual.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/utils/isRealtimePayloadEqual.test.ts b/src/utils/isRealtimePayloadEqual.test.ts index 4eec164e9..9945b5b77 100644 --- a/src/utils/isRealtimePayloadEqual.test.ts +++ b/src/utils/isRealtimePayloadEqual.test.ts @@ -1,4 +1,4 @@ -import { RealTimeTaskResponse } from '@/hoc/RealTime' +import type { RealTimeTaskResponse } from '@/hoc/RealTime' import { isTaskPayloadEqual } from '@/utils/isRealtimePayloadEqual' import { RealtimePostgresChangesPayload } from '@supabase/supabase-js' @@ -11,13 +11,13 @@ const buildTaskPayload = ({ }): RealtimePostgresChangesPayload => ({ commit_timestamp: '2026-07-08T12:00:00.000Z', - errors: null, + errors: [], eventType: 'UPDATE', new: newPayload, old: oldPayload, schema: 'public', table: 'Tasks', - }) as RealtimePostgresChangesPayload + }) as unknown as RealtimePostgresChangesPayload describe('isTaskPayloadEqual', () => { it('ignores no-op updates for task fields', () => { diff --git a/src/utils/isRealtimePayloadEqual.ts b/src/utils/isRealtimePayloadEqual.ts index 46f4f5aef..d3cedb491 100644 --- a/src/utils/isRealtimePayloadEqual.ts +++ b/src/utils/isRealtimePayloadEqual.ts @@ -1,5 +1,5 @@ -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' const taskRealtimeFields = [ @@ -71,8 +71,8 @@ const areRealtimeValuesEqual = (newValue: unknown, oldValue: unknown): boolean = export function isTaskPayloadEqual( payload: RealtimePostgresChangesPayload, ): boolean { - const newPayload = payload.new - const oldPayload = payload.old + const newPayload = isRecord(payload.new) ? payload.new : undefined + const oldPayload = isRecord(payload.old) ? payload.old : undefined if (!newPayload || !oldPayload) return true return taskRealtimeFields.every((field) => areRealtimeValuesEqual(newPayload[field], oldPayload[field])) } From a52c4a6688d82588f0137addc75c977aece0acfc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 12:09:31 +0000 Subject: [PATCH 3/3] Tighten realtime payload record narrowing Co-authored-by: Neil Raina --- src/utils/isRealtimePayloadEqual.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/utils/isRealtimePayloadEqual.ts b/src/utils/isRealtimePayloadEqual.ts index d3cedb491..8af7ef05f 100644 --- a/src/utils/isRealtimePayloadEqual.ts +++ b/src/utils/isRealtimePayloadEqual.ts @@ -41,6 +41,8 @@ const taskRealtimeFields = [ 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])) @@ -71,8 +73,8 @@ const areRealtimeValuesEqual = (newValue: unknown, oldValue: unknown): boolean = export function isTaskPayloadEqual( payload: RealtimePostgresChangesPayload, ): boolean { - const newPayload = isRecord(payload.new) ? payload.new : undefined - const oldPayload = isRecord(payload.old) ? payload.old : undefined + const newPayload = toRecord(payload.new) + const oldPayload = toRecord(payload.old) if (!newPayload || !oldPayload) return true return taskRealtimeFields.every((field) => areRealtimeValuesEqual(newPayload[field], oldPayload[field])) }