Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 38 additions & 36 deletions packages/core/realtime-js/src/phoenix/presenceAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default class PresenceAdapter {
}

get state(): RealtimePresenceState {
return transformState(this.presence.state)
return PresenceAdapter.transformState(this.presence.state)
}

onJoin(callback: OnJoin): void {
Expand All @@ -30,49 +30,51 @@ export default class PresenceAdapter {
onSync(callback: OnSync): void {
this.presence.onSync(callback)
}
}

/**
* Remove 'metas' key
* Change 'phx_ref' to 'presence_ref'
* Remove 'phx_ref' and 'phx_ref_prev'
*
* @example
* // returns {
* abc123: [
* { presence_ref: '2', user_id: 1 },
* { presence_ref: '3', user_id: 2 }
* ]
* }
* RealtimePresence.transformState({
* abc123: {
* metas: [
* { phx_ref: '2', phx_ref_prev: '1' user_id: 1 },
* { phx_ref: '3', user_id: 2 }
* ]
* }
* })
*
*/
function transformState(state: State): RealtimePresenceState {
state = cloneState(state)
/**
* @private
* Remove 'metas' key
* Change 'phx_ref' to 'presence_ref'
* Remove 'phx_ref' and 'phx_ref_prev'
*
* @example
* // returns {
* abc123: [
* { presence_ref: '2', user_id: 1 },
* { presence_ref: '3', user_id: 2 }
* ]
* }
* RealtimePresence.transformState({
* abc123: {
* metas: [
* { phx_ref: '2', phx_ref_prev: '1' user_id: 1 },
* { phx_ref: '3', user_id: 2 }
* ]
* }
* })
*
*/
static transformState(state: State): RealtimePresenceState {
state = cloneState(state)

return Object.getOwnPropertyNames(state).reduce((newState, key) => {
const presences = state[key]
return Object.getOwnPropertyNames(state).reduce((newState, key) => {
const presences = state[key]

newState[key] = presences.metas.map((presence) => {
presence['presence_ref'] = presence['phx_ref']
newState[key] = presences.metas.map((presence) => {
presence['presence_ref'] = presence['phx_ref']

delete presence['phx_ref']
delete presence['phx_ref_prev']
delete presence['phx_ref']
delete presence['phx_ref_prev']

return presence
}) as RealtimePresenceType[]
return presence
}) as RealtimePresenceType[]

return newState
}, {} as RealtimePresenceState)
return newState
}, {} as RealtimePresenceState)
}
}


function cloneState(state: State): State {
return JSON.parse(JSON.stringify(state))
}
Expand Down
228 changes: 3 additions & 225 deletions packages/core/realtime-js/test/RealtimeChannel.presence.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import assert from 'assert'
import { describe, beforeEach, afterEach, test, vi, expect } from 'vitest'
import RealtimeChannel from '../src/RealtimeChannel'
import RealtimePresence from '../src/RealtimePresence'
import {
setupRealtimeTest,
cleanupRealtimeTest,
Expand Down Expand Up @@ -135,8 +134,9 @@ describe('Presence message filtering', () => {
})

describe('Presence helper methods', () => {
test('gets presence state', () => {
channel.presence.state = { u1: [{ id: 1, presence_ref: '1' }] }
test('gets transformed presence state', () => {
// @ts-ignore - accessing private fields for testing
channel.presence.presenceAdapter.presence.state = { u1: { metas: [{ id: 1, phx_ref: '1' }] } }
assert.deepEqual(channel.presenceState(), {
u1: [{ id: 1, presence_ref: '1' }],
})
Expand Down Expand Up @@ -239,225 +239,3 @@ describe('Presence configuration override', () => {
})
})

describe('RealtimePresence static methods', () => {
// Helper function to clone objects (from original RealtimePresence tests)
const clone = (obj: any) => {
const cloned = JSON.parse(JSON.stringify(obj))
Object.entries(obj).map(([key, val]) => {
if (val === undefined) {
cloned[key] = undefined
}
})
return cloned
}

const fixtures = {
joins() {
return { u1: [{ id: 1, presence_ref: '1.2' }] }
},
leaves() {
return { u2: [{ id: 2, presence_ref: '2' }] }
},
state() {
return {
u1: [{ id: 1, presence_ref: '1' }],
u2: [{ id: 2, presence_ref: '2' }],
u3: [{ id: 3, presence_ref: '3' }],
}
},
}

describe('syncState functionality', () => {
test.each([
{
name: 'should sync empty state',
initialState: {},
newState: { u1: [{ id: 1, presence_ref: '1' }] },
expectedResult: { u1: [{ id: 1, presence_ref: '1' }] },
expectedJoined: {
u1: { current: [], newPres: [{ id: 1, presence_ref: '1' }] },
},
expectedLeft: {},
},
{
name: 'should handle onJoin and onLeave callbacks',
initialState: { u4: [{ id: 4, presence_ref: '4' }] },
newState: fixtures.state(),
expectedResult: fixtures.state(),
expectedJoined: {
u1: { current: [], newPres: [{ id: 1, presence_ref: '1' }] },
u2: { current: [], newPres: [{ id: 2, presence_ref: '2' }] },
u3: { current: [], newPres: [{ id: 3, presence_ref: '3' }] },
},
expectedLeft: {
u4: { current: [], leftPres: [{ id: 4, presence_ref: '4' }] },
},
},
{
name: 'should only join newly added presences',
initialState: { u3: [{ id: 3, presence_ref: '3' }] },
newState: {
u3: [
{ id: 3, presence_ref: '3' },
{ id: 3, presence_ref: '3.new' },
],
},
expectedResult: {
u3: [
{ id: 3, presence_ref: '3' },
{ id: 3, presence_ref: '3.new' },
],
},
expectedJoined: {
u3: {
current: [{ id: 3, presence_ref: '3' }],
newPres: [{ id: 3, presence_ref: '3.new' }],
},
},
expectedLeft: {},
},
])('$name', ({ initialState, newState, expectedResult, expectedJoined, expectedLeft }) => {
const stateBefore = clone(initialState)
const joined: any = {}
const left: any = {}

const onJoin = (key: string, current: any, newPres: any) => {
joined[key] = { current, newPres }
}
const onLeave = (key: string, current: any, leftPres: any) => {
left[key] = { current, leftPres }
}

// @ts-ignore - accessing static private method for testing
const result = RealtimePresence.syncState(initialState, newState, onJoin, onLeave)

assert.deepEqual(initialState, stateBefore)
assert.deepEqual(result, expectedResult)
assert.deepEqual(joined, expectedJoined)
assert.deepEqual(left, expectedLeft)
})
})

describe('syncDiff and utility methods', () => {
test.each([
{
name: 'sync empty state with joins',
initialState: {},
diff: { joins: { u1: [{ id: 1, presence_ref: '1' }] }, leaves: {} },
expected: { u1: [{ id: 1, presence_ref: '1' }] },
},
{
name: 'add presence and remove empty key',
initialState: fixtures.state(),
diff: { joins: fixtures.joins(), leaves: fixtures.leaves() },
expected: {
u1: [
{ id: 1, presence_ref: '1' },
{ id: 1, presence_ref: '1.2' },
],
u3: [{ id: 3, presence_ref: '3' }],
},
},
{
name: 'remove presence while leaving key if others exist',
initialState: {
u1: [
{ id: 1, presence_ref: '1' },
{ id: 1, presence_ref: '1.2' },
],
},
diff: { joins: {}, leaves: { u1: [{ id: 1, presence_ref: '1' }] } },
expected: { u1: [{ id: 1, presence_ref: '1.2' }] },
},
{
name: 'handle undefined callbacks',
initialState: { u1: [{ id: 1, presence_ref: '1' }] },
diff: {
joins: { u2: [{ id: 2, presence_ref: '2' }] },
leaves: { u1: [{ id: 1, presence_ref: '1' }] },
},
expected: { u2: [{ id: 2, presence_ref: '2' }] },
useUndefinedCallbacks: true,
},
])('syncDiff: $name', ({ initialState, diff, expected, useUndefinedCallbacks }) => {
// @ts-ignore - accessing static private method for testing
const result = useUndefinedCallbacks
? RealtimePresence.syncDiff(initialState, diff, undefined, undefined)
: RealtimePresence.syncDiff(initialState, diff)

assert.deepEqual(result, expected)
})

test('static utility methods work correctly', () => {
// Test map function
const state = {
u1: [{ id: 1, presence_ref: '1' }],
u2: [{ id: 2, presence_ref: '2' }],
}
// @ts-ignore - accessing static private method for testing
const mapResult = RealtimePresence.map(state, (key, presences) => ({
key,
count: presences.length,
}))
assert.deepEqual(mapResult, [
{ key: 'u1', count: 1 },
{ key: 'u2', count: 1 },
])

// Test transformState function
const rawState = {
u1: {
metas: [{ id: 1, phx_ref: '1', phx_ref_prev: 'prev1', name: 'User 1' }],
},
}
// @ts-ignore - accessing static private method for testing
const transformResult = RealtimePresence.transformState(rawState)
assert.deepEqual(transformResult, {
u1: [{ id: 1, presence_ref: '1', name: 'User 1' }],
})
assert.ok(!transformResult.u1[0].hasOwnProperty('phx_ref'))

// Test cloneDeep function
const original = { nested: { value: 1 }, array: [1, 2, 3] }
// @ts-ignore - accessing static private method for testing
const cloned = RealtimePresence.cloneDeep(original)
assert.deepEqual(cloned, original)
assert.notStrictEqual(cloned, original)
assert.notStrictEqual(cloned.nested, original.nested)
})
})

describe('instance behavior', () => {
test('handles custom events and pending diffs', () => {
// Test custom channel events
const customChannel = testSetup.socket.channel('custom-presence')
const customPresence = new RealtimePresence(customChannel, {
events: { state: 'custom_state', diff: 'custom_diff' },
})

customChannel._trigger('custom_state', {
user1: { metas: [{ id: 1, phx_ref: '1' }] },
})
assert.ok(customPresence.state.user1)
assert.equal(customPresence.state.user1[0].presence_ref, '1')

// Test pending diffs behavior
const presence = new RealtimePresence(channel)

// Send diff before state (should be pending)
channel._trigger('presence_diff', {
joins: {},
leaves: { u2: [{ id: 2, presence_ref: '2' }] },
})
assert.equal(presence.pendingDiffs.length, 1)

// Send state (should apply pending diffs)
channel.joinPush.ref = 'test-ref'
channel._trigger('presence_state', {
u1: [{ id: 1, presence_ref: '1' }],
u2: [{ id: 2, presence_ref: '2' }],
})
assert.equal(presence.pendingDiffs.length, 0)
})
})
})
Loading
Loading