Skip to content

Commit de9c518

Browse files
committed
fix(shared-state): avoid echoing received updates
1 parent 72d917d commit de9c518

2 files changed

Lines changed: 161 additions & 3 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type { DevframeNodeContext, DevframeRpcClientFunctions } from 'devframe/types'
2+
import type { DevframeRpcClient, DevframeRpcContext, RpcClientEvents } from './rpc'
3+
import assert from 'node:assert/strict'
4+
import { RpcFunctionsCollectorBase } from 'devframe/rpc'
5+
import { createEventEmitter } from 'devframe/utils/events'
6+
import { createSharedState } from 'devframe/utils/shared-state'
7+
import { expect, it, vi } from 'vitest'
8+
import { RpcFunctionsHostImpl } from '../node/host-functions'
9+
import { createRpcSharedStateClientHost } from './rpc-shared-state'
10+
11+
/** Delay only the transport writes; both endpoints retain their native shared-state logic. */
12+
function connectSharedState() {
13+
const server = new RpcFunctionsHostImpl({} as DevframeNodeContext)
14+
const clientFunctions = new RpcFunctionsCollectorBase<DevframeRpcClientFunctions, DevframeRpcContext>({} as DevframeRpcContext)
15+
const pendingWrites: (() => Promise<void>)[] = []
16+
const pendingBroadcasts: Promise<void>[] = []
17+
const connection: Partial<DevframeRpcClient> = {
18+
connectionMeta: { backend: 'websocket' },
19+
isTrusted: true,
20+
events: createEventEmitter<RpcClientEvents>(),
21+
client: clientFunctions,
22+
call: async (method, ...parameters) => {
23+
assert.equal(method, 'devframe:rpc:server-state:get')
24+
const key = parameters[0]
25+
assert.ok(typeof key === 'string')
26+
return await server.invokeLocal('devframe:rpc:server-state:get', key)
27+
},
28+
callEvent: async (method, ...parameters) => {
29+
if (method === 'devframe:rpc:server-state:set') {
30+
pendingWrites.push(async () => {
31+
await Reflect.apply(server.invokeLocal, server, [method, ...parameters])
32+
})
33+
}
34+
},
35+
}
36+
const broadcast = vi.spyOn(server, 'broadcast').mockImplementation((options) => {
37+
const delivery = clientFunctions.getHandler(options.method).then(handler => Reflect.apply(handler, undefined, options.args))
38+
pendingBroadcasts.push(delivery)
39+
return delivery
40+
})
41+
async function flushBroadcasts() {
42+
await Promise.all(pendingBroadcasts.splice(0))
43+
}
44+
return {
45+
server,
46+
client: createRpcSharedStateClientHost(connection as DevframeRpcClient),
47+
dispose: () => broadcast.mockRestore(),
48+
flushBroadcasts,
49+
async flushWrites() {
50+
for (const write of pendingWrites.splice(0))
51+
await write()
52+
await flushBroadcasts()
53+
},
54+
}
55+
}
56+
57+
for (const { name, enablePatches } of [
58+
{ name: 'full snapshots', enablePatches: false },
59+
{ name: 'patches', enablePatches: true },
60+
]) {
61+
it(`does not echo ${name} into a replacement state while preserving local and nested writes`, async () => {
62+
expect.assertions(9)
63+
const connection = connectSharedState()
64+
try {
65+
const original = await connection.server.sharedState.get('counter', {
66+
sharedState: createSharedState({ initialValue: { count: 0 }, enablePatches }),
67+
})
68+
const initialClient = await connection.client.get<{ count: number }>('counter')
69+
expect(initialClient.value()).toEqual({ count: 0 })
70+
original.mutate((draft) => {
71+
draft.count = 5
72+
})
73+
await connection.flushBroadcasts()
74+
expect(initialClient.value()).toEqual({ count: 5 })
75+
76+
connection.server.sharedState.delete('counter')
77+
connection.client.delete('counter')
78+
const replacement = await connection.server.sharedState.get('counter', {
79+
sharedState: createSharedState({ initialValue: { count: 6 }, enablePatches }),
80+
})
81+
const client = await connection.client.get<{ count: number }>('counter')
82+
expect(replacement.value()).toEqual({ count: 6 })
83+
await connection.flushWrites()
84+
expect(replacement.value()).toEqual({ count: 6 })
85+
expect(client.value()).toEqual({ count: 6 })
86+
87+
client.mutate((draft) => {
88+
draft.count = 7
89+
})
90+
await connection.flushWrites()
91+
expect(replacement.value()).toEqual({ count: 7 })
92+
expect(client.value()).toEqual({ count: 7 })
93+
94+
const stop = client.on('updated', (snapshot) => {
95+
if (snapshot.count !== 8)
96+
return
97+
stop()
98+
client.mutate((draft) => {
99+
draft.count = 9
100+
})
101+
})
102+
replacement.mutate((draft) => {
103+
draft.count = 8
104+
})
105+
await connection.flushBroadcasts()
106+
expect(client.value()).toEqual({ count: 9 })
107+
await connection.flushWrites()
108+
expect(replacement.value()).toEqual({ count: 9 })
109+
}
110+
finally {
111+
connection.dispose()
112+
}
113+
})
114+
}
115+
116+
it('forwards a callback write to another state that reuses the received sync ID', async () => {
117+
expect.assertions(3)
118+
const connection = connectSharedState()
119+
try {
120+
const original = await connection.server.sharedState.get('source', { initialValue: { count: 0 } })
121+
const derived = await connection.server.sharedState.get('derived', { initialValue: { count: 0 } })
122+
const sourceClient = await connection.client.get<{ count: number }>('source')
123+
const derivedClient = await connection.client.get<{ count: number }>('derived')
124+
sourceClient.on('updated', (snapshot, _patches, syncId) => {
125+
derivedClient.mutate((draft) => {
126+
draft.count = snapshot.count
127+
}, syncId)
128+
})
129+
original.mutate((draft) => {
130+
draft.count = 5
131+
})
132+
await connection.flushBroadcasts()
133+
expect(derivedClient.value()).toEqual({ count: 5 })
134+
await connection.flushWrites()
135+
expect(derived.value()).toEqual({ count: 5 })
136+
expect(original.value()).toEqual({ count: 5 })
137+
}
138+
finally {
139+
connection.dispose()
140+
}
141+
})

‎packages/devframe/src/client/rpc-shared-state.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,21 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
99
const stateDisposers = new Map<string, () => void>()
1010
const initialValues = new Map<string, any>()
1111
const keyAddedListeners = new Set<(key: string) => void>()
12+
/** Suppress the received change while preserving new writes from update listeners. */
13+
let receivedServerChange: { state: object, syncId: string } | undefined
1214
const isStaticBackend = rpc.connectionMeta.backend === 'static'
1315

16+
function applyServerChange(state: object, syncId: string, applyChange: () => void) {
17+
const previousChange = receivedServerChange
18+
receivedServerChange = { state, syncId }
19+
try {
20+
applyChange()
21+
}
22+
finally {
23+
receivedServerChange = previousChange
24+
}
25+
}
26+
1427
function mergeWithInitialValue(key: string, serverState: any): any {
1528
const initial = initialValues.get(key)
1629
if (initial && typeof initial === 'object' && !Array.isArray(initial)
@@ -27,7 +40,9 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
2740
const state = sharedState.get(key)
2841
if (!state || state.syncIds.has(syncId))
2942
return
30-
state.mutate(() => mergeWithInitialValue(key, fullState), syncId)
43+
applyServerChange(state, syncId, () => {
44+
state.mutate(() => mergeWithInitialValue(key, fullState), syncId)
45+
})
3146
},
3247
})
3348

@@ -38,14 +53,16 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
3853
const state = sharedState.get(key)
3954
if (!state || state.syncIds.has(syncId))
4055
return
41-
state.patch(patches, syncId)
56+
applyServerChange(state, syncId, () => {
57+
state.patch(patches, syncId)
58+
})
4259
},
4360
})
4461

4562
function registerSharedState<T extends object>(key: string, state: SharedState<T>) {
4663
const offs: (() => void)[] = []
4764
offs.push(state.on('updated', (fullState, patches, syncId) => {
48-
if (isStaticBackend)
65+
if (isStaticBackend || (receivedServerChange?.state === state && receivedServerChange.syncId === syncId))
4966
return
5067
if (patches) {
5168
rpc.callEvent('devframe:rpc:server-state:patch', key, patches, syncId)

0 commit comments

Comments
 (0)